belt 0.2.19 → 0.3.1

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.

Potentially problematic release.


This version of belt might be problematic. Click here for more details.

@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'json'
4
4
  require 'open3'
5
+ require_relative '../../belt/inflector'
5
6
 
6
7
  module Belt
7
8
  module CLI
@@ -17,13 +18,14 @@ module Belt
17
18
 
18
19
  def self.run(args)
19
20
  verbose = args.include?('--verbose') || args.include?('-v')
21
+ preflight = args.include?('--preflight')
20
22
 
21
23
  if args.include?('--help') || args.include?('-h')
22
24
  puts usage
23
25
  exit 0
24
26
  end
25
27
 
26
- new(verbose: verbose).run
28
+ new(verbose: verbose, preflight: preflight).run
27
29
  end
28
30
 
29
31
  def self.usage
@@ -33,24 +35,50 @@ module Belt
33
35
  Check system dependencies and AWS configuration for Belt.
34
36
 
35
37
  Options:
36
- -v, --verbose Show detailed output for each check
37
- -h, --help Show this help
38
+ -v, --verbose Show detailed output for each check
39
+ --preflight Run only deploy-critical checks (indexes, credentials)
40
+ -h, --help Show this help
38
41
  USAGE
39
42
  end
40
43
 
41
- def initialize(verbose: false)
44
+ def initialize(verbose: false, preflight: false)
42
45
  @verbose = verbose
46
+ @preflight = preflight
43
47
  @issues = []
44
48
  @warnings = []
45
49
  end
46
50
 
47
- def run
51
+ def run # rubocop:disable Naming/PredicateMethod
52
+ if @preflight
53
+ run_preflight
54
+ else
55
+ run_full
56
+ end
57
+
58
+ @issues.empty?
59
+ end
60
+
61
+ # Returns true if all preflight checks pass, false otherwise.
62
+ def run_preflight
63
+ check_aws_identity_quiet
64
+ check_table_indexes
65
+ check_cognito_auth
66
+ return if @issues.empty?
67
+
68
+ puts "\nPreflight checks failed:\n"
69
+ @issues.each { |i| puts " ✗ #{i}" }
70
+ puts ''
71
+ end
72
+
73
+ def run_full
48
74
  puts "Belt Doctor\n"
49
75
  puts "Checking your system for Belt prerequisites...\n\n"
50
76
 
51
77
  check_tools
52
78
  check_aws_credentials
53
79
  check_aws_identity
80
+ check_table_indexes
81
+ check_cognito_auth
54
82
 
55
83
  puts ''
56
84
  print_summary
@@ -132,28 +160,144 @@ module Belt
132
160
  print_ok('Authentication', "account #{account}")
133
161
  puts " ARN: #{arn}" if @verbose
134
162
  else
135
- error = output.strip
136
- if error.include?('ExpiredToken') || error.include?('expired')
137
- print_fail('Authentication', 'credentials expired')
138
- puts ' Run: aws sso login'
139
- @issues << 'AWS credentials are expired — run `aws sso login`'
140
- elsif error.include?('InvalidClientTokenId') || error.include?('SignatureDoesNotMatch')
141
- print_fail('Authentication', 'invalid credentials')
142
- puts ' Your access key or secret is incorrect.'
143
- puts ' Run: aws configure'
144
- @issues << 'AWS credentials are invalid'
145
- elsif error.include?('Could not connect') || error.include?('Unable to locate credentials')
146
- print_fail('Authentication', 'unable to authenticate')
147
- puts ' Run: aws configure sso # or set AWS_PROFILE'
148
- @issues << 'Unable to authenticate with AWS'
163
+ handle_aws_identity_error(output.strip)
164
+ end
165
+ end
166
+
167
+ def check_aws_identity_quiet
168
+ _, status = Open3.capture2e('aws', 'sts', 'get-caller-identity')
169
+ return if status.success?
170
+
171
+ @issues << 'AWS credentials invalid or expired — run `aws sso login`'
172
+ end
173
+
174
+ def handle_aws_identity_error(error)
175
+ if error.include?('ExpiredToken') || error.include?('expired')
176
+ print_fail('Authentication', 'credentials expired')
177
+ puts ' Run: aws sso login'
178
+ @issues << 'AWS credentials are expired — run `aws sso login`'
179
+ elsif error.include?('InvalidClientTokenId') || error.include?('SignatureDoesNotMatch')
180
+ print_fail('Authentication', 'invalid credentials')
181
+ puts ' Your access key or secret is incorrect.'
182
+ puts ' Run: aws configure'
183
+ @issues << 'AWS credentials are invalid'
184
+ elsif error.include?('Could not connect') || error.include?('Unable to locate credentials')
185
+ print_fail('Authentication', 'unable to authenticate')
186
+ puts ' Run: aws configure sso # or set AWS_PROFILE'
187
+ @issues << 'Unable to authenticate with AWS'
188
+ else
189
+ print_fail('Authentication', 'failed')
190
+ puts " #{error.lines.first&.strip}" if error.length.positive?
191
+ @issues << 'AWS authentication failed'
192
+ end
193
+ end
194
+
195
+ def check_table_indexes
196
+ return unless Belt.root?
197
+
198
+ models_dir = File.join(Belt.root, 'lambda/models')
199
+ dynamodb_tf = File.join(Belt.root, 'infrastructure/modules/app/dynamodb.tf')
200
+
201
+ return unless Dir.exist?(models_dir)
202
+
203
+ puts ''
204
+ puts '── Tables & Indexes ──'
205
+
206
+ unless File.exist?(dynamodb_tf)
207
+ print_warn('dynamodb.tf', 'not found — run `belt setup tables` to generate')
208
+ @warnings << 'dynamodb.tf not generated — run `belt setup tables`'
209
+ return
210
+ end
211
+
212
+ tf_content = File.read(dynamodb_tf)
213
+ model_files = Dir.glob(File.join(models_dir, '*.rb'))
214
+ .reject { |f| File.basename(f) == 'application_record.rb' }
215
+
216
+ model_files.each do |file|
217
+ check_model_indexes(file, tf_content)
218
+ end
219
+ end
220
+
221
+ def check_model_indexes(file, tf_content)
222
+ content = File.read(file)
223
+ class_match = content.match(/^class\s+(\w+)\s*<\s*ApplicationRecord/)
224
+ return unless class_match
225
+
226
+ model_name = Belt::Inflector.underscore(class_match[1])
227
+ table_name = Belt::Inflector.pluralize(model_name)
228
+
229
+ # Check if table exists in dynamodb.tf
230
+ unless tf_content.include?("resource \"aws_dynamodb_table\" \"#{table_name}\"")
231
+ print_fail(table_name, 'table not in dynamodb.tf')
232
+ @issues << "Table '#{table_name}' missing from dynamodb.tf — run `belt setup tables`"
233
+ return
234
+ end
235
+
236
+ # Extract expected indexes from belongs_to
237
+ expected_indexes = extract_expected_indexes(content)
238
+ return if expected_indexes.empty?
239
+
240
+ # Check each expected index exists in the TF file
241
+ # Scope check to this table's resource block
242
+ table_block = tf_content[/resource "aws_dynamodb_table" "#{table_name}" \{.*?^\}/m]
243
+ return unless table_block
244
+
245
+ missing = []
246
+ expected_indexes.each do |idx|
247
+ if table_block.include?("name = \"#{idx[:name]}\"")
248
+ print_ok(table_name, idx[:name]) if @verbose
149
249
  else
150
- print_fail('Authentication', 'failed')
151
- puts " #{error.lines.first&.strip}" if error.length.positive?
152
- @issues << 'AWS authentication failed'
250
+ missing << idx
251
+ end
252
+ end
253
+
254
+ if missing.empty?
255
+ print_ok(table_name, "#{expected_indexes.size} index#{'es' if expected_indexes.size > 1} configured")
256
+ else
257
+ missing.each do |idx|
258
+ print_fail(table_name, "missing #{idx[:name]} (required by belongs_to :#{idx[:association]})")
259
+ @issues << "Table '#{table_name}' missing index '#{idx[:name]}' " \
260
+ '— run `belt setup tables` then `belt deploy`'
153
261
  end
154
262
  end
155
263
  end
156
264
 
265
+ def extract_expected_indexes(content)
266
+ indexes = []
267
+ content.lines.reject { |line| line.strip.start_with?('#') }.join
268
+ .scan(/belongs_to\s+:(\w+)/) do |match|
269
+ association_name = match[0]
270
+ indexes << {
271
+ name: "#{Belt::Inflector.classify(association_name)}Index",
272
+ association: association_name
273
+ }
274
+ end
275
+ indexes
276
+ end
277
+
278
+ def check_cognito_auth
279
+ return unless Belt.root?
280
+
281
+ routes_file = Belt.routes_file
282
+ return unless routes_file
283
+
284
+ content = File.read(routes_file)
285
+
286
+ # Check if any routes use auth: :cognito
287
+ uses_cognito = content.include?('auth: :cognito')
288
+ return unless uses_cognito
289
+
290
+ # Check if cognito.tf exists
291
+ cognito_tf = File.join(Belt.root, 'infrastructure/modules/app/cognito.tf')
292
+ return if File.exist?(cognito_tf)
293
+
294
+ puts ''
295
+ puts '── Authentication ──'
296
+ print_warn('Cognito', 'routes use auth: :cognito but no cognito.tf found')
297
+ puts ' Run: belt generate auth'
298
+ @warnings << 'Routes use auth: :cognito but no Cognito pool is configured — run `belt generate auth`'
299
+ end
300
+
157
301
  def print_summary
158
302
  if @issues.empty? && @warnings.empty?
159
303
  puts '✓ All checks passed — you\'re ready to belt!'
@@ -15,7 +15,8 @@ module Belt
15
15
  module CLI
16
16
  class GenerateCommand
17
17
  TEMPLATE_DIR = File.expand_path('../../templates/generate', __dir__)
18
- GENERATORS = %w[scaffold resource model controller environment frontend views auth].freeze
18
+
19
+ GENERATORS = %w[scaffold resource model controller environment frontend views index auth].freeze
19
20
 
20
21
  include AppDetection
21
22
 
@@ -127,6 +128,8 @@ module Belt
127
128
 
128
129
  return Belt::CLI::ViewsCommand.run(args) if generator == 'views'
129
130
 
131
+ return Belt::CLI::IndexCommand.run(['add'] + args) if generator == 'index'
132
+
130
133
  return Belt::CLI::AuthCommand.run(args) if generator == 'auth'
131
134
 
132
135
  name = args.shift
@@ -290,7 +293,9 @@ module Belt
290
293
  when 'model'
291
294
  check_model_collision! unless @force
292
295
  generate_model_standalone
293
- when 'controller' then generate_controller
296
+ when 'controller'
297
+ generate_controller
298
+ inject_routes
294
299
  end
295
300
  end
296
301
 
@@ -351,18 +356,6 @@ module Belt
351
356
  inject_parent_associations
352
357
  sync_tables
353
358
  generate_views_if_frontend
354
- puts "\n✓ Scaffold '#{@singular_name}' generated!"
355
- puts "\nFiles created/updated:"
356
- puts " lambda/models/#{@singular_name}.rb"
357
- puts " lambda/controllers/#{@app_name}/#{@resource_name}_controller.rb"
358
- puts " #{find_routes_file_path || 'config/routes.rb'} (updated)"
359
- puts " #{find_contracts_file_path || 'config/contracts.rb'} (updated)"
360
- puts " lambda/lib/routes/#{@app_name}_routes.rb (updated)"
361
- puts " frontend/src/pages/#{@resource_name}/ (views)" if Dir.exist?('frontend/src')
362
- @references.each do |ref|
363
- parent_model_path = "lambda/models/#{ref[:referenced_model]}.rb"
364
- puts " #{parent_model_path} (updated — added has_many)" if File.exist?(parent_model_path)
365
- end
366
359
  end
367
360
 
368
361
  def generate_model_standalone
@@ -386,25 +379,26 @@ module Belt
386
379
 
387
380
  def inject_routes
388
381
  routes_file = find_routes_file_path
389
- return unless routes_file && File.exist?(routes_file)
390
382
 
391
- content = File.read(routes_file)
392
- tables_arg = @fields.any? ? ", tables: [:#{@resource_name}]" : ''
383
+ if routes_file && File.exist?(routes_file)
384
+ content = File.read(routes_file)
385
+ tables_arg = @fields.any? ? ", tables: [:#{@resource_name}]" : ''
393
386
 
394
- # If this resource has a reference to a parent that already has routes, nest it
395
- parent_ref = @references.find do |ref|
396
- parent_resource = Belt::Inflector.pluralize(ref[:referenced_model])
397
- content.match?(/resources :#{Regexp.escape(parent_resource)}\b/)
398
- end
387
+ # If this resource has a reference to a parent that already has routes, nest it
388
+ parent_ref = @references.find do |ref|
389
+ parent_resource = Belt::Inflector.pluralize(ref[:referenced_model])
390
+ content.match?(/resources :#{Regexp.escape(parent_resource)}\b/)
391
+ end
399
392
 
400
- if parent_ref
401
- inject_nested_route(content, routes_file, parent_ref, tables_arg)
393
+ if parent_ref
394
+ inject_nested_route(content, routes_file, parent_ref, tables_arg)
395
+ else
396
+ inject_top_level_route(content, routes_file, tables_arg)
397
+ end
402
398
  else
403
- inject_top_level_route(content, routes_file, tables_arg)
399
+ # Legacy projects without config/routes.rb — update the manifest directly
400
+ inject_route_manifest
404
401
  end
405
-
406
- # Also update route manifest
407
- inject_route_manifest
408
402
  end
409
403
 
410
404
  def inject_nested_route(content, routes_file, parent_ref, tables_arg)
@@ -456,18 +450,25 @@ module Belt
456
450
  elsif content.include?('# resources :posts')
457
451
  content.sub!('# resources :posts', resource_line)
458
452
  else
459
- # Find the target namespace block and insert before its closing `end`
453
+ # Find the target gateway (or legacy namespace) block and insert before its closing `end`
454
+ gateway_pattern = /^(\s*)gateway :#{Regexp.escape(@app_name)}\b[^\n]*do\s*\n(.*?)^\1end/m
460
455
  namespace_pattern = /^(\s*)namespace :#{Regexp.escape(@app_name)}\b[^\n]*do\s*\n(.*?)^\1end/m
461
456
 
462
- if content.match?(namespace_pattern)
463
- content.sub!(namespace_pattern) do |match|
457
+ target_pattern = if content.match?(gateway_pattern)
458
+ gateway_pattern
459
+ elsif content.match?(namespace_pattern)
460
+ namespace_pattern
461
+ end
462
+
463
+ if target_pattern
464
+ content.sub!(target_pattern) do |match|
464
465
  indent = ::Regexp.last_match(1)
465
466
  match.sub(/^(#{indent})end\z/m, "#{indent} #{resource_line}\n#{indent}end")
466
467
  end
467
468
  else
468
- single_ns_pattern = /^(\s*)namespace :\w+\b[^\n]*do\s*\n(.*?)^\1end/m
469
- if content.match?(single_ns_pattern)
470
- content.sub!(single_ns_pattern) do |match|
469
+ single_gw_pattern = /^(\s*)(?:gateway|namespace) :\w+\b[^\n]*do\s*\n(.*?)^\1end/m
470
+ if content.match?(single_gw_pattern)
471
+ content.sub!(single_gw_pattern) do |match|
471
472
  indent = ::Regexp.last_match(1)
472
473
  match.sub(/^(#{indent})end\z/m, "#{indent} #{resource_line}\n#{indent}end")
473
474
  end
@@ -596,8 +597,7 @@ module Belt
596
597
  next unless File.exist?(parent_model_path)
597
598
 
598
599
  content = File.read(parent_model_path)
599
- has_many_line = " has_many :#{@resource_name}, foreign_key: '#{ref[:referenced_model]}_id', " \
600
- "index: '#{Belt::Inflector.classify(ref[:referenced_model])}Index'"
600
+ has_many_line = " has_many :#{@resource_name}"
601
601
 
602
602
  next if content.include?("has_many :#{@resource_name}")
603
603
 
@@ -632,7 +632,7 @@ module Belt
632
632
  return unless Dir.exist?('frontend/src')
633
633
  return if @skip_views
634
634
 
635
- Belt::CLI::ViewsCommand.new(@name, @fields, force: @force).generate
635
+ Belt::CLI::ViewsCommand.new(@name, @fields, force: @force, quiet: true).generate
636
636
  end
637
637
  end
638
638
  end
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'tables_command'
4
+ require_relative '../inflector'
5
+
6
+ module Belt
7
+ module CLI
8
+ class IndexCommand
9
+ MODULE_DIR = 'infrastructure/modules/app'
10
+ DYNAMODB_TF = File.join(MODULE_DIR, 'dynamodb.tf')
11
+
12
+ def self.run(args)
13
+ action = args.shift
14
+
15
+ case action
16
+ when 'add', nil
17
+ add(args)
18
+ when 'remove', 'rm'
19
+ remove(args)
20
+ when '--help', '-h'
21
+ puts usage
22
+ else
23
+ # Treat first arg as table name if no subcommand
24
+ add([action] + args)
25
+ end
26
+ end
27
+
28
+ def self.usage
29
+ <<~USAGE
30
+ Usage: belt generate index <table> <IndexName> --partition-key <key> [--sort-key <key>]
31
+ belt destroy index <table> <IndexName>
32
+
33
+ Add or remove a Global Secondary Index (GSI) from dynamodb.tf.
34
+
35
+ Examples:
36
+ belt generate index messages ConversationIndex --partition-key conversation_id
37
+ belt generate index messages RecentByUserIndex --partition-key user_id --sort-key created_at
38
+ belt destroy index messages ConversationIndex
39
+
40
+ Note: After modifying indexes, run `belt deploy` to apply changes to AWS.
41
+ Adding a GSI to an existing table takes ~5-10 minutes (AWS limitation).
42
+ USAGE
43
+ end
44
+
45
+ def self.add(args)
46
+ table, index_name, partition_key, sort_key = parse_add_args(args)
47
+ new(table, index_name, partition_key: partition_key, sort_key: sort_key).add
48
+ end
49
+
50
+ def self.remove(args)
51
+ table = args.shift
52
+ index_name = args.shift
53
+
54
+ if table.nil? || index_name.nil?
55
+ abort "Usage: belt destroy index <table> <IndexName>\n\n" \
56
+ 'Example: belt destroy index messages ConversationIndex'
57
+ end
58
+
59
+ new(table, index_name).remove
60
+ end
61
+
62
+ def self.parse_add_args(args)
63
+ table = args.shift
64
+ index_name = args.shift
65
+ partition_key = nil
66
+ sort_key = nil
67
+
68
+ i = 0
69
+ while i < args.length
70
+ case args[i]
71
+ when '--partition-key', '-p'
72
+ i += 1
73
+ partition_key = args[i]
74
+ when '--sort-key', '-s'
75
+ i += 1
76
+ sort_key = args[i]
77
+ end
78
+ i += 1
79
+ end
80
+
81
+ if table.nil? || index_name.nil? || partition_key.nil?
82
+ abort "Usage: belt generate index <table> <IndexName> --partition-key <key> [--sort-key <key>]\n\n" \
83
+ 'Example: belt generate index messages ConversationIndex --partition-key conversation_id'
84
+ end
85
+
86
+ [table, index_name, partition_key, sort_key]
87
+ end
88
+
89
+ def initialize(table, index_name, partition_key: nil, sort_key: nil)
90
+ @table = table
91
+ @index_name = index_name
92
+ @partition_key = partition_key
93
+ @sort_key = sort_key
94
+ end
95
+
96
+ def add
97
+ validate_tf_exists!
98
+
99
+ content = File.read(DYNAMODB_TF)
100
+ table_resource = "aws_dynamodb_table\" \"#{@table}\""
101
+
102
+ unless content.include?(table_resource)
103
+ abort "Error: Table '#{@table}' not found in #{DYNAMODB_TF}.\n" \
104
+ 'Run `belt setup tables` first to generate the table.'
105
+ end
106
+
107
+ if content.include?("name = \"#{@index_name}\"")
108
+ puts " skip #{@index_name} (already exists on #{@table})"
109
+ return
110
+ end
111
+
112
+ dynamo_pk = Belt::Inflector.camelize_lower(@partition_key)
113
+ dynamo_sk = @sort_key ? Belt::Inflector.camelize_lower(@sort_key) : nil
114
+
115
+ # Build the GSI block
116
+ gsi_block = build_gsi_block(dynamo_pk, dynamo_sk)
117
+
118
+ # Build attribute blocks for new keys
119
+ attr_blocks = build_attribute_blocks(content, dynamo_pk, dynamo_sk)
120
+
121
+ # Insert into the table resource
122
+ insert_gsi(content, gsi_block, attr_blocks)
123
+
124
+ puts " create #{@index_name} on #{@table} (partition: #{dynamo_pk}#{", sort: #{dynamo_sk}" if dynamo_sk})"
125
+ puts "\n Run `belt deploy` to apply. Adding a GSI to an existing table takes ~5-10 min."
126
+ end
127
+
128
+ def remove
129
+ validate_tf_exists!
130
+
131
+ content = File.read(DYNAMODB_TF)
132
+
133
+ unless content.include?("name = \"#{@index_name}\"")
134
+ abort "Error: Index '#{@index_name}' not found in #{DYNAMODB_TF}."
135
+ end
136
+
137
+ # Remove the GSI block
138
+ content.sub!(/\n\s*global_secondary_index \{\n\s*name\s*=\s*"#{Regexp.escape(@index_name)}".*?\n\s*\}/m, '')
139
+
140
+ File.write(DYNAMODB_TF, content)
141
+ puts " remove #{@index_name} from #{@table}"
142
+ puts "\n Run `belt deploy` to apply."
143
+ end
144
+
145
+ private
146
+
147
+ def validate_tf_exists!
148
+ return if File.exist?(DYNAMODB_TF)
149
+
150
+ abort "Error: #{DYNAMODB_TF} not found.\nRun `belt setup tables` first."
151
+ end
152
+
153
+ def build_gsi_block(dynamo_pk, dynamo_sk)
154
+ lines = []
155
+ lines << ' global_secondary_index {'
156
+ lines << " name = \"#{@index_name}\""
157
+ lines << " hash_key = \"#{dynamo_pk}\""
158
+ lines << " range_key = \"#{dynamo_sk}\"" if dynamo_sk
159
+ lines << ' projection_type = "ALL"'
160
+ lines << ' }'
161
+ lines.join("\n")
162
+ end
163
+
164
+ def build_attribute_blocks(content, dynamo_pk, dynamo_sk)
165
+ blocks = []
166
+ [dynamo_pk, dynamo_sk].compact.each do |key|
167
+ next if content.include?("name = \"#{key}\"")
168
+
169
+ blocks << "\n attribute {\n name = \"#{key}\"\n type = \"S\"\n }"
170
+ end
171
+ blocks.join
172
+ end
173
+
174
+ def insert_gsi(content, gsi_block, attr_blocks)
175
+ # Find the table's resource block and insert before point_in_time_recovery
176
+ table_pattern = /resource "aws_dynamodb_table" "#{Regexp.escape(@table)}" \{.*?point_in_time_recovery/m
177
+
178
+ content.sub!(table_pattern) do |match|
179
+ # Insert attributes after last existing attribute block
180
+ unless attr_blocks.empty?
181
+ match.sub!(/( attribute \{.*?\n \})(?!.*attribute)/m) { |attr_match| "#{attr_match}#{attr_blocks}" }
182
+ end
183
+
184
+ # Insert GSI before point_in_time_recovery
185
+ match.sub('point_in_time_recovery', "#{gsi_block}\n\n point_in_time_recovery")
186
+ end
187
+
188
+ File.write(DYNAMODB_TF, content)
189
+ end
190
+ end
191
+ end
192
+ end
@@ -34,7 +34,7 @@ module Belt
34
34
  SUPPORTED_KEYS = %w[
35
35
  timeout memory_size env_vars env_keys
36
36
  s3_buckets dynamodb_tables sns_triggers sqs_triggers
37
- reserved_concurrency ephemeral_storage
37
+ reserved_concurrency ephemeral_storage iam_policy_arns
38
38
  ].freeze
39
39
 
40
40
  def self.run(args)
@@ -112,6 +112,7 @@ module Belt
112
112
  sqs_triggers SQS queue triggers
113
113
  reserved_concurrency Reserved concurrency limit
114
114
  ephemeral_storage Ephemeral storage in MB (512-10240)
115
+ iam_policy_arns Additional IAM policy ARNs (supports ref())
115
116
  HELP
116
117
  end
117
118
 
@@ -14,9 +14,10 @@ module Belt
14
14
  non_param = segments.reject { |s| s.start_with?(':', '{') }
15
15
  return gateway.name if non_param.empty?
16
16
 
17
- # Nested resources (/posts/{id}/comments) and scoped resources (/admin/users):
18
- # join non-param segments → posts/comments, admin/users
19
- return non_param.map { |s| s.gsub('-', '_') }.join('/') if route.resource? && non_param.length > 1
17
+ # Nested resources (/posts/{id}/comments): use the last non-param segment
18
+ # as the controller name (matches Rails — nesting affects URL, not controller lookup).
19
+ # Scoped resources (/admin/users) still use the full path when controller is explicitly set.
20
+ return non_param.last.gsub('-', '_') if route.resource? && non_param.length > 1
20
21
 
21
22
  # For non-resource routes with a single segment (e.g., post '/signup' in :onboarding),
22
23
  # the segment is the action name, not the controller. Use the gateway name as controller.
@@ -250,18 +250,8 @@ module Belt
250
250
  def output_concise(routes)
251
251
  return puts('No routes defined.') if routes.empty?
252
252
 
253
- multi_gateway = routes.map { |r| r[:gateway] }.uniq.length > 1
254
253
  verb_w = [routes.map { |r| r[:verb].length }.max, 6].max
255
254
  path_w = [routes.map { |r| r[:path].length }.max, 4].max
256
-
257
- if multi_gateway
258
- output_concise_multi_gateway(routes, verb_w, path_w)
259
- else
260
- output_concise_single_gateway(routes, verb_w, path_w)
261
- end
262
- end
263
-
264
- def output_concise_multi_gateway(routes, verb_w, path_w)
265
255
  gw_w = [routes.map { |r| r[:gateway].to_s.length }.max, 7].max
266
256
  lam_w = [routes.map { |r| r[:lambda].length }.max, 6].max
267
257
 
@@ -278,15 +268,6 @@ module Belt
278
268
  end
279
269
  end
280
270
 
281
- def output_concise_single_gateway(routes, verb_w, path_w)
282
- puts "#{'VERB'.ljust(verb_w)} #{'PATH'.ljust(path_w)} CONTROLLER#ACTION"
283
- puts '-' * (verb_w + path_w + 30)
284
-
285
- routes.each do |r|
286
- puts "#{r[:verb].ljust(verb_w)} #{r[:path].ljust(path_w)} #{r[:controller]}##{r[:action]}"
287
- end
288
- end
289
-
290
271
  def route_specificity(path, verb)
291
272
  segments = path.split('/').reject(&:empty?)
292
273
  param_count = segments.count { |s| s.start_with?('{') }
@@ -243,7 +243,9 @@ module Belt
243
243
  end
244
244
 
245
245
  def table_name(model_name)
246
- "${var.app_name}-${var.environment}-#{Belt::Inflector.pluralize(model_name)}"
246
+ # Dasherize to match ActiveItem's table_name_for convention:
247
+ # class_name.underscore.dasherize.pluralize
248
+ "${var.app_name}-${var.environment}-#{Belt::Inflector.pluralize(model_name).tr('_', '-')}"
247
249
  end
248
250
  end
249
251
  end
@@ -75,10 +75,11 @@ module Belt
75
75
  end
76
76
  end
77
77
 
78
- def initialize(name, fields, force: false)
78
+ def initialize(name, fields, force: false, quiet: false)
79
79
  @name = name.downcase.gsub(/[^a-z0-9_]/, '_')
80
80
  @fields = fields
81
81
  @force = force
82
+ @quiet = quiet
82
83
  @overwrite_all = false
83
84
  @singular_name = Belt::Inflector.singularize(@name)
84
85
  @resource_name = Belt::Inflector.pluralize(@singular_name)
@@ -103,14 +104,7 @@ module Belt
103
104
 
104
105
  inject_routes
105
106
 
106
- puts "\n✓ Views for '#{@singular_name}' generated!"
107
- puts "\nFiles created:"
108
- puts " #{pages_dir}/#{@plural_class_name}Index.jsx"
109
- puts " #{pages_dir}/#{@class_name}Show.jsx"
110
- puts " #{pages_dir}/#{@class_name}New.jsx"
111
- puts " #{pages_dir}/#{@class_name}Edit.jsx"
112
- puts " #{pages_dir}/#{@class_name}Form.jsx"
113
- puts ' frontend/src/App.jsx (updated)'
107
+ puts "\n✓ Views for '#{@singular_name}' generated!" unless @quiet
114
108
  end
115
109
 
116
110
  private