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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +47 -0
- data/README.md +42 -3
- data/lib/belt/action_router.rb +21 -17
- data/lib/belt/cli/app_detection.rb +4 -2
- data/lib/belt/cli/auth_command.rb +75 -4
- data/lib/belt/cli/console_command.rb +17 -2
- data/lib/belt/cli/deploy_command.rb +10 -2
- data/lib/belt/cli/destroy_command.rb +3 -1
- data/lib/belt/cli/doctor_command.rb +166 -22
- data/lib/belt/cli/generate_command.rb +37 -37
- data/lib/belt/cli/index_command.rb +192 -0
- data/lib/belt/cli/lambda_config_command.rb +2 -1
- data/lib/belt/cli/routes_command/route_inference.rb +4 -3
- data/lib/belt/cli/routes_command.rb +0 -19
- data/lib/belt/cli/tables_command.rb +3 -1
- data/lib/belt/cli/views_command.rb +3 -9
- data/lib/belt/cli.rb +1 -0
- data/lib/belt/inflector.rb +8 -0
- data/lib/belt/route_dsl.rb +196 -85
- data/lib/belt/version.rb +1 -1
- data/lib/templates/generate/auth/cognito.tf.erb +0 -2
- data/lib/templates/generate/auth/frontend/ConfirmEmail.jsx +1 -1
- data/lib/templates/generate/auth/frontend/Login.jsx +2 -4
- data/lib/templates/generate/auth/frontend/SignUp.jsx +1 -1
- data/lib/templates/generate/auth/frontend/auth.css +157 -0
- data/lib/templates/generate/auth/frontend/auth.js +5 -1
- data/lib/templates/generate/model.rb.erb +4 -6
- data/lib/templates/new_app/AGENTS.md.erb +2 -2
- data/lib/templates/new_app/config/lambda/api.yml.erb +1 -0
- data/lib/templates/new_app/config/routes.rb.erb +3 -1
- data/lib/templates/new_app/lambda/api.rb.erb +1 -1
- metadata +31 -1
|
@@ -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
|
|
37
|
-
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
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'
|
|
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
|
-
|
|
392
|
-
|
|
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
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
-
|
|
401
|
-
|
|
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
|
-
|
|
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?(
|
|
463
|
-
|
|
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
|
-
|
|
469
|
-
if content.match?(
|
|
470
|
-
content.sub!(
|
|
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}
|
|
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)
|
|
18
|
-
#
|
|
19
|
-
|
|
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
|
-
|
|
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
|