belt 0.2.19 → 0.2.20

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: 7f706cafa79246d178c2aee6bfb05536bcb5f03111a24f13624e769ef7d9916a
4
- data.tar.gz: a99d09c6d472d4826acf78dee6a4fded952c8745071b63c6ea4f54dbfd6b0aef
3
+ metadata.gz: 983c166381b409ee19c2a23c1439be35180892299af76570097d206dea1ba1a4
4
+ data.tar.gz: f7888dca2b10805bed099da525b968b90e511c1b8381990ba85ab80dc23a9263
5
5
  SHA512:
6
- metadata.gz: 2009deb03f2f725c4e960e73abf7745b8a9fdd5899ea76178b3942f21c504fe76338912ba57f05b913db3a18494f635c508e4696a3c347e40ddcb973e4c61541
7
- data.tar.gz: b84417ce5814e266715b5dc54b4a965261f8011fee552f9f594c82137751565ff29b20f17444b2be986abfd79c02059b935ae5aa97fcbdbbfdcc79ffafa0421b
6
+ metadata.gz: 253f76decf02f960c12011b95570b04213c33800eecfaf70415071f8a2792cae2480a71251cc0ec1b565b837b1c9229f6522c6452124f63560334c6cb6b838d4
7
+ data.tar.gz: 5393e1c8cdef68b50dee5bca41a299d02560ecc9e65718e8ed45e2cb09b5ffabaf74eefc11b47e59f35fd7b78b602b866db7028eae82246e0da76b14910eca53
@@ -101,14 +101,48 @@ module Belt
101
101
  write_cognito_tf
102
102
  write_cognito_outputs_tf
103
103
  patch_main_tf
104
+ patch_env_outputs
104
105
  generate_frontend_auth if frontend?
105
106
 
106
107
  puts "\n✓ Auth generated!"
108
+ print_next_steps
109
+ end
110
+
111
+ def print_next_steps
107
112
  puts "\nNext steps:"
108
- puts ' 1. Review infrastructure/modules/app/cognito.tf'
109
- puts ' 2. Customize password policy, MFA, or Lambda triggers as needed'
110
- puts ' 3. Run `belt apply <env>` to deploy'
111
- puts ' 4. Create your account: aws cognito-idp admin-create-user ...' unless @signup
113
+ puts ' 1. Add auth: :cognito to your routes namespace:'
114
+ puts ''
115
+ puts ' namespace :api, auth: :cognito do'
116
+ puts ' # your resources...'
117
+ puts ' end'
118
+ puts ''
119
+ if frontend?
120
+ puts ' 2. Wire auth into your frontend/src/App.jsx:'
121
+ puts ''
122
+ puts " import Login from './pages/auth/Login'"
123
+ puts " import ProtectedRoute from './components/ProtectedRoute'"
124
+ puts ''
125
+ puts ' // Add login route:'
126
+ puts ' <Route path="/login" element={<Login onLogin={() => window.location.href = \'/\'} />} />'
127
+ puts ''
128
+ puts ' // Wrap protected routes:'
129
+ puts ' <Route path="/*" element={<ProtectedRoute><YourApp /></ProtectedRoute>} />'
130
+ puts ''
131
+ puts ' 3. Deploy: belt deploy'
132
+ print_create_user_step(4) unless @signup
133
+ else
134
+ puts ' 2. Deploy: belt deploy'
135
+ print_create_user_step(3) unless @signup
136
+ end
137
+ end
138
+
139
+ def print_create_user_step(step_num)
140
+ puts " #{step_num}. Create your account:"
141
+ puts ' aws cognito-idp admin-create-user \\'
142
+ puts ' --user-pool-id <pool-id-from-terraform-output> \\'
143
+ puts ' --username your@email.com \\'
144
+ puts ' --temporary-password TempPass123 \\'
145
+ puts ' --message-action SUPPRESS'
112
146
  end
113
147
 
114
148
  def remove
@@ -167,6 +201,7 @@ module Belt
167
201
  pages_dir = 'frontend/src/pages/auth'
168
202
  FileUtils.mkdir_p(pages_dir)
169
203
  copy_frontend_file(frontend_template_dir, 'Login.jsx', File.join(pages_dir, 'Login.jsx'))
204
+ copy_frontend_file(frontend_template_dir, 'auth.css', File.join(pages_dir, 'auth.css'))
170
205
  if @signup
171
206
  # Generate auth pages
172
207
  copy_frontend_file(frontend_template_dir, 'SignUp.jsx', File.join(pages_dir, 'SignUp.jsx'))
@@ -244,6 +279,42 @@ module Belt
244
279
  insert_cognito_into_resource(content, main_tf, arn_value)
245
280
  end
246
281
 
282
+ def patch_env_outputs
283
+ env_dirs = Dir.glob('infrastructure/*/outputs.tf')
284
+ .reject { |f| f.include?('modules') }
285
+
286
+ env_dirs.each do |outputs_file|
287
+ content = File.read(outputs_file)
288
+
289
+ next if content.include?('cognito_user_pool_id')
290
+
291
+ cognito_outputs = @pools.map do |pool|
292
+ suffix = pool[:suffix]
293
+ <<~HCL
294
+
295
+ output "cognito_user_pool_id#{suffix}" {
296
+ description = "Cognito User Pool ID#{pool[:label]}"
297
+ value = module.app.cognito_user_pool_id#{suffix}
298
+ }
299
+
300
+ output "cognito_client_id#{suffix}" {
301
+ description = "Cognito User Pool Client ID#{pool[:label]}"
302
+ value = module.app.cognito_client_id#{suffix}
303
+ }
304
+
305
+ output "cognito_region" {
306
+ description = "AWS region for Cognito"
307
+ value = var.aws_region
308
+ }
309
+ HCL
310
+ end.join
311
+
312
+ File.write(outputs_file, content + cognito_outputs)
313
+ env_name = File.basename(File.dirname(outputs_file))
314
+ puts " update #{outputs_file} (added cognito outputs for #{env_name})"
315
+ end
316
+ end
317
+
247
318
  def build_arn_value(arns)
248
319
  if arns.length == 1
249
320
  "[#{arns.first}]"
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'optparse'
4
+ require 'stringio'
4
5
  require_relative 'environment_config'
5
6
 
6
7
  module Belt
@@ -59,7 +60,10 @@ module Belt
59
60
  boot_app
60
61
  puts banner
61
62
  ARGV.clear
62
- require 'irb'
63
+ suppress_warnings do
64
+ require 'irb'
65
+ require 'rdoc'
66
+ end
63
67
  IRB.start
64
68
  end
65
69
 
@@ -72,7 +76,7 @@ module Belt
72
76
  end
73
77
 
74
78
  def boot_app
75
- require 'bundler/setup'
79
+ suppress_warnings { require 'bundler/setup' }
76
80
 
77
81
  environment_file = File.join(Belt.root, 'lambda', 'config', 'environment.rb')
78
82
  if File.exist?(environment_file)
@@ -132,6 +136,17 @@ module Belt
132
136
  result.inspect
133
137
  end
134
138
  end
139
+
140
+ def suppress_warnings
141
+ original_verbose = $VERBOSE
142
+ $VERBOSE = nil
143
+ original_stderr = $stderr
144
+ $stderr = StringIO.new
145
+ yield
146
+ ensure
147
+ $stderr = original_stderr
148
+ $VERBOSE = original_verbose
149
+ end
135
150
  end
136
151
  end
137
152
  end
@@ -145,6 +145,7 @@ module Belt
145
145
 
146
146
  def run
147
147
  validate!
148
+ run_preflight_checks!
148
149
  env_dir = File.join(@infra_dir, @env)
149
150
 
150
151
  load_and_apply_env_config!
@@ -263,6 +264,14 @@ module Belt
263
264
  "Create it with: belt generate environment #{@env}"
264
265
  end
265
266
 
267
+ def run_preflight_checks!
268
+ require_relative 'doctor_command'
269
+ doctor = DoctorCommand.new(preflight: true)
270
+ return if doctor.run
271
+
272
+ abort 'Deploy blocked. Fix the issues above, then try again.'
273
+ end
274
+
266
275
  def validate_aws!
267
276
  stdout, status = Open3.capture2('aws', 'sts', 'get-caller-identity', '--output', 'json')
268
277
  unless status.success?
@@ -10,7 +10,7 @@ require_relative '../inflector'
10
10
  module Belt
11
11
  module CLI
12
12
  class DestroyCommand
13
- GENERATORS = %w[scaffold resource model controller environment frontend views auth].freeze
13
+ GENERATORS = %w[scaffold resource model controller environment frontend views index auth].freeze
14
14
 
15
15
  include AppDetection
16
16
 
@@ -61,6 +61,8 @@ module Belt
61
61
  exit 1
62
62
  end
63
63
  new(generator, name, args.map { |a| parse_field(a) }).destroy
64
+ when 'index'
65
+ Belt::CLI::IndexCommand.run(['remove'] + args)
64
66
  else
65
67
  name = args.shift
66
68
  if name.nil? || name.empty?
@@ -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)
@@ -596,8 +590,7 @@ module Belt
596
590
  next unless File.exist?(parent_model_path)
597
591
 
598
592
  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'"
593
+ has_many_line = " has_many :#{@resource_name}"
601
594
 
602
595
  next if content.include?("has_many :#{@resource_name}")
603
596
 
@@ -632,7 +625,7 @@ module Belt
632
625
  return unless Dir.exist?('frontend/src')
633
626
  return if @skip_views
634
627
 
635
- Belt::CLI::ViewsCommand.new(@name, @fields, force: @force).generate
628
+ Belt::CLI::ViewsCommand.new(@name, @fields, force: @force, quiet: true).generate
636
629
  end
637
630
  end
638
631
  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?('{') }
@@ -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
data/lib/belt/cli.rb CHANGED
@@ -6,6 +6,7 @@ require_relative 'cli/env_resolver'
6
6
  require_relative 'cli/new_command'
7
7
  require_relative 'cli/generate_command'
8
8
  require_relative 'cli/destroy_command'
9
+ require_relative 'cli/index_command'
9
10
  require_relative 'cli/frontend_command'
10
11
  require_relative 'cli/frontend_setup_command'
11
12
  require_relative 'cli/frontend_deploy_command'
@@ -42,6 +42,14 @@ module Belt
42
42
  word.to_s.split('_').map(&:capitalize).join
43
43
  end
44
44
 
45
+ # "conversation_id" → "conversationId"
46
+ def camelize_lower(word)
47
+ parts = word.to_s.split('_')
48
+ return word if parts.empty?
49
+
50
+ parts.first + parts[1..].map(&:capitalize).join
51
+ end
52
+
45
53
  # "BlogPost" → "blog_post"
46
54
  def underscore(word)
47
55
  word.to_s
@@ -65,8 +65,9 @@ module Belt
65
65
  resource_name = name.to_s
66
66
  singular = @gateway.send(:singularize, resource_name)
67
67
  param_name = options[:param] || "#{singular}_id"
68
+ # Auto-add this resource's table before merging inherited tables
69
+ options = options.merge(tables: [resource_name.to_sym]) unless options.key?(:tables)
68
70
  options = merge_inherited_options(options)
69
- options = @gateway.send(:auto_infer_tables, resource_name, options)
70
71
  resource_options = options.merge(route_type: :resources)
71
72
  actions = @gateway.send(:determine_actions, options)
72
73
 
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.2.19'
4
+ VERSION = '0.2.20'
5
5
  end
@@ -1,5 +1,5 @@
1
1
  import { useState } from 'react'
2
- import { confirmSignUp } from '../lib/auth'
2
+ import { confirmSignUp } from '../../lib/auth'
3
3
 
4
4
  export default function ConfirmEmail() {
5
5
  const params = new URLSearchParams(window.location.search)
@@ -1,5 +1,6 @@
1
1
  import { useState } from 'react'
2
- import { signIn, completeNewPassword } from '../lib/auth'
2
+ import { signIn, completeNewPassword } from '../../lib/auth'
3
+ import './auth.css'
3
4
 
4
5
  export default function Login({ onLogin }) {
5
6
  const [email, setEmail] = useState('')
@@ -72,9 +73,6 @@ export default function Login({ onLogin }) {
72
73
  <button type="submit" disabled={loading}>
73
74
  {loading ? 'Signing in...' : 'Sign In'}
74
75
  </button>
75
- <p className="auth-link">
76
- Don't have an account? <a href="/signup">Sign up</a>
77
- </p>
78
76
  </form>
79
77
  </div>
80
78
  )
@@ -1,5 +1,5 @@
1
1
  import { useState } from 'react'
2
- import { signUp } from '../lib/auth'
2
+ import { signUp } from '../../lib/auth'
3
3
 
4
4
  export default function SignUp() {
5
5
  const [email, setEmail] = useState('')
@@ -0,0 +1,157 @@
1
+ .auth-page {
2
+ --auth-bg: #f6f8fa;
3
+ --auth-card-bg: #ffffff;
4
+ --auth-border: #d0d7de;
5
+ --auth-text: #1f2328;
6
+ --auth-muted: #656d76;
7
+ --auth-input-bg: #f6f8fa;
8
+ --auth-btn: #2da44e;
9
+ --auth-btn-hover: #2c974b;
10
+ --auth-btn-disabled-bg: #94d3a2;
11
+ --auth-btn-disabled-text: #fff;
12
+ --auth-error-bg: rgba(248, 81, 73, 0.08);
13
+ --auth-error-border: rgba(248, 81, 73, 0.4);
14
+ --auth-error-text: #cf222e;
15
+ --auth-link: #0969da;
16
+ --auth-focus: #0969da;
17
+ }
18
+
19
+ @media (prefers-color-scheme: dark) {
20
+ .auth-page {
21
+ --auth-bg: #0d1117;
22
+ --auth-card-bg: #161b22;
23
+ --auth-border: #30363d;
24
+ --auth-text: #e6edf3;
25
+ --auth-muted: #8b949e;
26
+ --auth-input-bg: #0d1117;
27
+ --auth-btn: #238636;
28
+ --auth-btn-hover: #2ea043;
29
+ --auth-btn-disabled-bg: #21262d;
30
+ --auth-btn-disabled-text: #484f58;
31
+ --auth-error-bg: rgba(248, 81, 73, 0.1);
32
+ --auth-error-border: rgba(248, 81, 73, 0.4);
33
+ --auth-error-text: #f85149;
34
+ --auth-link: #58a6ff;
35
+ --auth-focus: #58a6ff;
36
+ }
37
+ }
38
+
39
+ .auth-page {
40
+ min-height: 100vh;
41
+ display: flex;
42
+ align-items: center;
43
+ justify-content: center;
44
+ background: var(--auth-bg);
45
+ padding: 1rem;
46
+ }
47
+
48
+ .auth-form {
49
+ background: var(--auth-card-bg);
50
+ border: 1px solid var(--auth-border);
51
+ border-radius: 12px;
52
+ padding: 2.5rem;
53
+ width: 100%;
54
+ max-width: 380px;
55
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
56
+ }
57
+
58
+ .auth-form h2 {
59
+ color: var(--auth-text);
60
+ font-size: 1.5rem;
61
+ margin: 0 0 0.5rem;
62
+ text-align: center;
63
+ }
64
+
65
+ .auth-form p {
66
+ color: var(--auth-muted);
67
+ font-size: 0.85rem;
68
+ text-align: center;
69
+ margin: 0 0 1.5rem;
70
+ }
71
+
72
+ .auth-form input {
73
+ display: block;
74
+ width: 100%;
75
+ padding: 0.75rem 1rem;
76
+ margin-bottom: 0.75rem;
77
+ background: var(--auth-input-bg);
78
+ border: 1px solid var(--auth-border);
79
+ border-radius: 8px;
80
+ color: var(--auth-text);
81
+ font-size: 0.9rem;
82
+ outline: none;
83
+ transition: border-color 0.2s;
84
+ box-sizing: border-box;
85
+ }
86
+
87
+ .auth-form input:focus {
88
+ border-color: var(--auth-focus);
89
+ }
90
+
91
+ .auth-form input::placeholder {
92
+ color: var(--auth-muted);
93
+ }
94
+
95
+ .auth-form button {
96
+ display: block;
97
+ width: 100%;
98
+ padding: 0.75rem;
99
+ margin-top: 0.5rem;
100
+ background: var(--auth-btn);
101
+ border: none;
102
+ border-radius: 8px;
103
+ color: #fff;
104
+ font-size: 0.9rem;
105
+ font-weight: 600;
106
+ cursor: pointer;
107
+ transition: background 0.2s;
108
+ }
109
+
110
+ .auth-form button:hover {
111
+ background: var(--auth-btn-hover);
112
+ }
113
+
114
+ .auth-form button:disabled {
115
+ background: var(--auth-btn-disabled-bg);
116
+ color: var(--auth-btn-disabled-text);
117
+ cursor: not-allowed;
118
+ }
119
+
120
+ .auth-error {
121
+ background: var(--auth-error-bg);
122
+ border: 1px solid var(--auth-error-border);
123
+ border-radius: 8px;
124
+ color: var(--auth-error-text);
125
+ padding: 0.6rem 0.75rem;
126
+ font-size: 0.8rem;
127
+ margin-bottom: 1rem;
128
+ text-align: center;
129
+ }
130
+
131
+ .auth-link {
132
+ color: var(--auth-muted);
133
+ font-size: 0.8rem;
134
+ text-align: center;
135
+ margin-top: 1.25rem !important;
136
+ }
137
+
138
+ .auth-link a {
139
+ color: var(--auth-link);
140
+ text-decoration: none;
141
+ }
142
+
143
+ .auth-link a:hover {
144
+ text-decoration: underline;
145
+ }
146
+
147
+ .auth-btn {
148
+ display: inline-block;
149
+ padding: 0.6rem 1.5rem;
150
+ background: var(--auth-btn);
151
+ border-radius: 8px;
152
+ color: #fff;
153
+ text-decoration: none;
154
+ font-weight: 600;
155
+ font-size: 0.85rem;
156
+ margin-top: 1rem;
157
+ }
@@ -4,13 +4,11 @@ class <%= @class_name %> < ApplicationRecord
4
4
  <% @references.each do |ref| -%>
5
5
  belongs_to :<%= ref[:referenced_model] %>
6
6
  <% end -%>
7
- <% if @references.any? && (@regular_fields.any? || @references.any?) -%>
7
+ <% if @references.any? && @regular_fields.any? -%>
8
8
 
9
9
  <% end -%>
10
- <% @references.each do |ref| -%>
11
- attr_accessor :<%= ref[:referenced_model] %>_id
12
- <% end -%>
13
- <% @regular_fields.each do |field| -%>
14
- attr_accessor :<%= field[:name] %>
10
+ <% all_accessors = @regular_fields.map { |f| ":#{f[:name]}" } -%>
11
+ <% if all_accessors.any? -%>
12
+ attr_accessor <%= all_accessors.join(', ') %>
15
13
  <% end -%>
16
14
  end
@@ -5,6 +5,7 @@
5
5
  # timeout Lambda timeout in seconds (default: 30)
6
6
  # memory_size Lambda memory in MB (default: 256)
7
7
  # env_vars Environment variables (static values or ref() for Terraform values)
8
+ # iam_policy_arns Additional IAM policy ARNs to attach (supports ref())
8
9
  # dynamodb_tables DynamoDB table access (by name — ARNs built by convention)
9
10
  # s3_buckets S3 bucket access declarations
10
11
  # sns_triggers SNS topic triggers
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.2.19
4
+ version: 0.2.20
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stowzilla
@@ -43,6 +43,20 @@ dependencies:
43
43
  - - ">="
44
44
  - !ruby/object:Gem::Version
45
45
  version: '7.0'
46
+ - !ruby/object:Gem::Dependency
47
+ name: irb
48
+ requirement: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - ">="
51
+ - !ruby/object:Gem::Version
52
+ version: '1.0'
53
+ type: :runtime
54
+ prerelease: false
55
+ version_requirements: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: '1.0'
46
60
  - !ruby/object:Gem::Dependency
47
61
  name: lambda_loadout
48
62
  requirement: !ruby/object:Gem::Requirement
@@ -57,6 +71,20 @@ dependencies:
57
71
  - - "~>"
58
72
  - !ruby/object:Gem::Version
59
73
  version: '0.0'
74
+ - !ruby/object:Gem::Dependency
75
+ name: rdoc
76
+ requirement: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: '6.0'
81
+ type: :runtime
82
+ prerelease: false
83
+ version_requirements: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: '6.0'
60
88
  description: Belt provides a collection of lightweight utilities for Ruby applications.
61
89
  email:
62
90
  - andy@stowzilla.com
@@ -95,6 +123,7 @@ files:
95
123
  - lib/belt/cli/frontend_setup_command.rb
96
124
  - lib/belt/cli/generate_command.rb
97
125
  - lib/belt/cli/generator_registry.rb
126
+ - lib/belt/cli/index_command.rb
98
127
  - lib/belt/cli/lambda_config_command.rb
99
128
  - lib/belt/cli/logs_command.rb
100
129
  - lib/belt/cli/new_command.rb
@@ -149,6 +178,7 @@ files:
149
178
  - lib/templates/generate/auth/frontend/ProtectedRoute.jsx
150
179
  - lib/templates/generate/auth/frontend/SignUp.jsx
151
180
  - lib/templates/generate/auth/frontend/apiClient.js
181
+ - lib/templates/generate/auth/frontend/auth.css
152
182
  - lib/templates/generate/auth/frontend/auth.js
153
183
  - lib/templates/generate/controller.rb.erb
154
184
  - lib/templates/generate/model.rb.erb