belt 0.2.17 → 0.3.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +226 -0
  3. data/README.md +42 -3
  4. data/lib/belt/action_router.rb +34 -19
  5. data/lib/belt/cli/app_detection.rb +30 -3
  6. data/lib/belt/cli/auth_command.rb +395 -0
  7. data/lib/belt/cli/console_command.rb +17 -2
  8. data/lib/belt/cli/deploy_command.rb +51 -17
  9. data/lib/belt/cli/destroy_command.rb +62 -25
  10. data/lib/belt/cli/doctor_command.rb +166 -22
  11. data/lib/belt/cli/environment_command.rb +12 -0
  12. data/lib/belt/cli/explain_command.rb +112 -0
  13. data/lib/belt/cli/frontend_command.rb +31 -10
  14. data/lib/belt/cli/frontend_deploy_command.rb +110 -21
  15. data/lib/belt/cli/frontend_env_command.rb +56 -19
  16. data/lib/belt/cli/frontend_env_map.rb +30 -10
  17. data/lib/belt/cli/frontend_registry.rb +373 -0
  18. data/lib/belt/cli/frontend_setup_command.rb +71 -5
  19. data/lib/belt/cli/generate_command.rb +61 -42
  20. data/lib/belt/cli/index_command.rb +192 -0
  21. data/lib/belt/cli/lambda_config_command.rb +9 -3
  22. data/lib/belt/cli/routes_command/request_model_inference.rb +131 -0
  23. data/lib/belt/cli/routes_command/route_inference.rb +4 -3
  24. data/lib/belt/cli/routes_command.rb +31 -19
  25. data/lib/belt/cli/server_command.rb +28 -15
  26. data/lib/belt/cli/tables_command.rb +3 -1
  27. data/lib/belt/cli/terraform_command.rb +9 -0
  28. data/lib/belt/cli/views_command.rb +15 -15
  29. data/lib/belt/cli/zip_artifact_builder.rb +199 -0
  30. data/lib/belt/cli.rb +17 -9
  31. data/lib/belt/docs/backups.md +101 -0
  32. data/lib/belt/docs/console.md +65 -0
  33. data/lib/belt/docs/controllers.md +155 -0
  34. data/lib/belt/docs/deployment.md +166 -0
  35. data/lib/belt/docs/frontend.md +103 -0
  36. data/lib/belt/docs/generators.md +137 -0
  37. data/lib/belt/docs/lambda_handler.md +105 -0
  38. data/lib/belt/docs/models.md +160 -0
  39. data/lib/belt/docs/observability.md +94 -0
  40. data/lib/belt/docs/plugins.md +94 -0
  41. data/lib/belt/docs/routing.md +138 -0
  42. data/lib/belt/docs/structure.md +96 -0
  43. data/lib/belt/inflector.rb +8 -0
  44. data/lib/belt/route_dsl.rb +318 -100
  45. data/lib/belt/version.rb +1 -1
  46. data/lib/templates/frontend/react/env.yml.example +2 -2
  47. data/lib/templates/generate/auth/cognito.tf.erb +71 -0
  48. data/lib/templates/generate/auth/cognito_outputs.tf.erb +19 -0
  49. data/lib/templates/generate/auth/frontend/ConfirmEmail.jsx +55 -0
  50. data/lib/templates/generate/auth/frontend/Login.jsx +79 -0
  51. data/lib/templates/generate/auth/frontend/ProtectedRoute.jsx +9 -0
  52. data/lib/templates/generate/auth/frontend/SignUp.jsx +58 -0
  53. data/lib/templates/generate/auth/frontend/apiClient.js +34 -0
  54. data/lib/templates/generate/auth/frontend/auth.css +157 -0
  55. data/lib/templates/generate/auth/frontend/auth.js +121 -0
  56. data/lib/templates/generate/model.rb.erb +4 -6
  57. data/lib/templates/module/frontend.tf.erb +49 -43
  58. data/lib/templates/module/outputs.tf.erb +1 -1
  59. data/lib/templates/new_app/AGENTS.md.erb +170 -20
  60. data/lib/templates/new_app/config/lambda/api.yml.erb +1 -0
  61. data/lib/templates/new_app/config/routes.rb.erb +8 -3
  62. data/lib/templates/new_app/lambda/api.rb.erb +1 -1
  63. metadata +56 -1
@@ -2,6 +2,8 @@
2
2
 
3
3
  require 'fileutils'
4
4
  require_relative 'app_detection'
5
+ require_relative 'auth_command'
6
+ require_relative 'frontend_registry'
5
7
  require_relative 'generator_registry'
6
8
  require_relative 'tables_command'
7
9
  require_relative '../inflector'
@@ -9,7 +11,7 @@ require_relative '../inflector'
9
11
  module Belt
10
12
  module CLI
11
13
  class DestroyCommand
12
- GENERATORS = %w[scaffold resource model controller environment frontend views].freeze
14
+ GENERATORS = %w[scaffold resource model controller environment frontend views index auth].freeze
13
15
 
14
16
  include AppDetection
15
17
 
@@ -50,21 +52,28 @@ module Belt
50
52
  flags = parse_environment_flags(args)
51
53
  new(generator, name, [], **flags).destroy
52
54
  when 'frontend'
53
- new(generator, nil, []).destroy
55
+ frontend_name = FrontendRegistry.extract_flag!(args, '--frontend')
56
+ new(generator, nil, [], frontend_name: frontend_name).destroy
57
+ when 'auth'
58
+ Belt::CLI::AuthCommand.destroy(args)
54
59
  when 'views'
60
+ frontend_name = FrontendRegistry.extract_flag!(args, '--frontend')
55
61
  name = args.shift
56
62
  if name.nil? || name.empty?
57
- puts 'Usage: belt destroy views <name>'
63
+ puts 'Usage: belt destroy views <name> [--frontend NAME]'
58
64
  exit 1
59
65
  end
60
- new(generator, name, args.map { |a| parse_field(a) }).destroy
66
+ new(generator, name, args.map { |a| parse_field(a) }, frontend_name: frontend_name).destroy
67
+ when 'index'
68
+ Belt::CLI::IndexCommand.run(['remove'] + args)
61
69
  else
70
+ frontend_name = FrontendRegistry.extract_flag!(args, '--frontend')
62
71
  name = args.shift
63
72
  if name.nil? || name.empty?
64
73
  puts "Usage: belt destroy #{generator} <name>"
65
74
  exit 1
66
75
  end
67
- new(generator, name, args.map { |a| parse_field(a) }).destroy
76
+ new(generator, name, args.map { |a| parse_field(a) }, frontend_name: frontend_name).destroy
68
77
  end
69
78
  end
70
79
 
@@ -98,8 +107,9 @@ module Belt
98
107
  resource Alias for scaffold
99
108
  model Remove an ActiveItem model
100
109
  controller Remove a controller
110
+ auth Remove Cognito user pool infrastructure
101
111
  environment Remove a deployment environment and tear down infrastructure
102
- frontend Remove the frontend/ directory
112
+ frontend Remove a frontend directory (use --frontend NAME when several exist)
103
113
  views Remove React pages for a resource
104
114
 
105
115
  Environment options:
@@ -114,7 +124,9 @@ module Belt
114
124
  belt d environment dev --skip-terraform
115
125
  belt d environment dev --force
116
126
  belt d frontend
127
+ belt d frontend --frontend ops
117
128
  belt d views post
129
+ belt d views post --frontend ops
118
130
 
119
131
  ⚠ This is destructive. Files will be permanently deleted.
120
132
 
@@ -123,12 +135,14 @@ module Belt
123
135
  HELP
124
136
  end
125
137
 
126
- def initialize(generator, name, fields, force: false, skip_terraform: false)
138
+ # rubocop:disable Metrics/ParameterLists -- keyword options for destroy flags
139
+ def initialize(generator, name, fields, force: false, skip_terraform: false, frontend_name: nil)
127
140
  @generator = generator
128
141
  @name = name&.downcase&.gsub(/[^a-z0-9_]/, '_')
129
142
  @fields = fields
130
143
  @force = force
131
144
  @skip_terraform = skip_terraform
145
+ @frontend_name = frontend_name
132
146
  @app_name = detect_namespace
133
147
  @singular_name = @name ? Belt::Inflector.singularize(@name) : nil
134
148
  @resource_name = @singular_name ? Belt::Inflector.pluralize(@singular_name) : nil
@@ -136,6 +150,7 @@ module Belt
136
150
  @removed = []
137
151
  @updated = []
138
152
  end
153
+ # rubocop:enable Metrics/ParameterLists
139
154
 
140
155
  def destroy
141
156
  case @generator
@@ -344,39 +359,62 @@ module Belt
344
359
  end
345
360
 
346
361
  def destroy_frontend
347
- dir = 'frontend'
362
+ frontend = FrontendRegistry.new.resolve!(@frontend_name)
363
+ dir = frontend.path
348
364
  if Dir.exist?(dir)
349
365
  FileUtils.rm_rf(dir)
350
366
  @removed << dir
351
367
  puts " remove #{dir}/"
352
368
 
353
- # Remove frontend.tf from the module
354
- module_frontend = 'infrastructure/modules/app/frontend.tf'
355
- if File.exist?(module_frontend)
356
- FileUtils.rm_f(module_frontend)
357
- @removed << module_frontend
358
- puts " remove #{module_frontend}"
369
+ tf_file = File.join('infrastructure/modules/app', "#{frontend.tf_name}.tf")
370
+ if File.exist?(tf_file)
371
+ FileUtils.rm_f(tf_file)
372
+ @removed << tf_file
373
+ puts " remove #{tf_file}"
359
374
  end
360
375
 
361
- puts "\n✓ Frontend removed!"
376
+ puts "\n✓ #{frontend.label.capitalize} removed!"
362
377
  else
363
- puts '✗ No frontend/ directory found.'
378
+ puts "✗ No #{dir}/ directory found."
364
379
  exit 1
365
380
  end
366
381
  end
367
382
 
383
+ def target_frontends
384
+ registry = FrontendRegistry.new
385
+ return [] if registry.empty?
386
+
387
+ if @frontend_name
388
+ [registry.resolve!(@frontend_name)]
389
+ else
390
+ registry.all
391
+ end
392
+ end
393
+
368
394
  def destroy_views
369
395
  return unless @resource_name
370
396
 
371
- pages_dir = "frontend/src/pages/#{@resource_name}"
372
-
373
- if Dir.exist?(pages_dir)
374
- FileUtils.rm_rf(pages_dir)
375
- @removed << pages_dir
376
- puts " remove #{pages_dir}/"
397
+ targets = target_frontends
398
+ if targets.empty?
399
+ pages_dir = "frontend/src/pages/#{@resource_name}"
400
+ if Dir.exist?(pages_dir)
401
+ FileUtils.rm_rf(pages_dir)
402
+ @removed << pages_dir
403
+ puts " remove #{pages_dir}/"
404
+ end
405
+ remove_view_routes_from('frontend/src/App.jsx')
406
+ return
377
407
  end
378
408
 
379
- remove_view_routes
409
+ targets.each do |frontend|
410
+ pages_dir = "#{frontend.src_dir}/pages/#{@resource_name}"
411
+ if Dir.exist?(pages_dir)
412
+ FileUtils.rm_rf(pages_dir)
413
+ @removed << pages_dir
414
+ puts " remove #{pages_dir}/"
415
+ end
416
+ remove_view_routes_from(frontend.app_jsx)
417
+ end
380
418
  end
381
419
 
382
420
  def remove_file(path)
@@ -461,8 +499,7 @@ module Belt
461
499
  Belt::CLI::TablesCommand.sync_all_environments
462
500
  end
463
501
 
464
- def remove_view_routes
465
- app_jsx = 'frontend/src/App.jsx'
502
+ def remove_view_routes_from(app_jsx)
466
503
  return unless File.exist?(app_jsx)
467
504
 
468
505
  content = File.read(app_jsx)
@@ -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!'
@@ -3,6 +3,8 @@
3
3
  require 'fileutils'
4
4
  require 'erb'
5
5
  require_relative 'app_detection'
6
+ require_relative 'frontend_registry'
7
+ require_relative 'frontend_setup_command'
6
8
 
7
9
  module Belt
8
10
  module CLI
@@ -52,6 +54,8 @@ module Belt
52
54
  puts " create #{dest_path}" unless @quiet
53
55
  end
54
56
 
57
+ append_extra_frontend_outputs(File.join(dest_dir, 'outputs.tf'))
58
+
55
59
  return if @quiet || !@announce
56
60
 
57
61
  puts "\n✓ Environment '#{@env_name}' created!"
@@ -82,6 +86,14 @@ module Belt
82
86
  File.write(dest_path, content)
83
87
  end
84
88
 
89
+ def append_extra_frontend_outputs(outputs_file)
90
+ FrontendRegistry.new.all.each do |frontend|
91
+ next if frontend.tf_name == 'frontend'
92
+
93
+ FrontendSetupCommand.append_env_outputs_for(frontend, outputs_file)
94
+ end
95
+ end
96
+
85
97
  # Resolve the state bucket name to use in backend.tf.
86
98
  # Priority: existing sibling backend.tf → AWS account ID → bare placeholder.
87
99
  def resolve_state_bucket
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Belt
4
+ module CLI
5
+ class ExplainCommand
6
+ DOCS_DIR = File.expand_path('../docs', __dir__)
7
+
8
+ TOPICS = Dir.glob(File.join(DOCS_DIR, '*.md')).map do |path|
9
+ File.basename(path, '.md')
10
+ end.sort.freeze
11
+
12
+ ALIASES = {
13
+ 'routes' => 'routing',
14
+ 'route' => 'routing',
15
+ 'router' => 'routing',
16
+ 'controller' => 'controllers',
17
+ 'model' => 'models',
18
+ 'activeitem' => 'models',
19
+ 'dynamodb' => 'models',
20
+ 'deploy' => 'deployment',
21
+ 'deploying' => 'deployment',
22
+ 'terraform' => 'deployment',
23
+ 'generate' => 'generators',
24
+ 'generator' => 'generators',
25
+ 'scaffold' => 'generators',
26
+ 'handler' => 'lambda_handler',
27
+ 'lambda' => 'lambda_handler',
28
+ 'entry_point' => 'lambda_handler',
29
+ 'entrypoint' => 'lambda_handler',
30
+ 'project' => 'structure',
31
+ 'layout' => 'structure',
32
+ 'directory' => 'structure',
33
+ 'logs' => 'observability',
34
+ 'logging' => 'observability',
35
+ 'metrics' => 'observability',
36
+ 'backup' => 'backups',
37
+ 'plugin' => 'plugins',
38
+ 'irb' => 'console',
39
+ 'repl' => 'console',
40
+ 'frontends' => 'frontend',
41
+ 'spa' => 'frontend'
42
+ }.freeze
43
+
44
+ def self.run(args)
45
+ if args.empty? || args.include?('--help') || args.include?('-h')
46
+ puts usage
47
+ return
48
+ end
49
+
50
+ topic = resolve_topic(args.first)
51
+
52
+ if topic.nil?
53
+ puts "Unknown topic: #{args.first}\n\n"
54
+ puts 'Available topics:'
55
+ TOPICS.each { |t| puts " #{t}" }
56
+ puts "\nRun `belt explain <topic>` for details."
57
+ exit 1
58
+ end
59
+
60
+ display_topic(topic)
61
+ end
62
+
63
+ def self.resolve_topic(input)
64
+ normalized = input.downcase.gsub('-', '_')
65
+ return normalized if TOPICS.include?(normalized)
66
+ return ALIASES[normalized] if ALIASES[normalized]
67
+
68
+ # Fuzzy match: find topics that start with or contain the input
69
+ match = TOPICS.find { |t| t.start_with?(normalized) }
70
+ match || TOPICS.find { |t| t.include?(normalized) }
71
+ end
72
+
73
+ def self.display_topic(topic)
74
+ path = File.join(DOCS_DIR, "#{topic}.md")
75
+ content = File.read(path)
76
+ puts content
77
+ end
78
+
79
+ def self.usage
80
+ topic_list = TOPICS.map { |t| " #{t}" }.join("\n")
81
+
82
+ <<~USAGE
83
+ Usage: belt explain <topic>
84
+
85
+ Display documentation for a Belt concept or feature.
86
+
87
+ Available topics:
88
+ #{topic_list}
89
+
90
+ Aliases:
91
+ routes, route, router → routing
92
+ controller → controllers
93
+ model, activeitem → models
94
+ deploy, terraform → deployment
95
+ generate, scaffold → generators
96
+ handler, lambda → lambda_handler
97
+ project, layout → structure
98
+ logs, logging, metrics → observability
99
+ backup → backups
100
+ plugin → plugins
101
+ irb, repl → console
102
+
103
+ Examples:
104
+ belt explain routing
105
+ belt explain controllers
106
+ belt explain deploy
107
+ belt explain models
108
+ USAGE
109
+ end
110
+ end
111
+ end
112
+ end
@@ -5,6 +5,7 @@ require 'erb'
5
5
  require 'json'
6
6
  require 'open3'
7
7
  require_relative 'app_detection'
8
+ require_relative 'frontend_registry'
8
9
 
9
10
  module Belt
10
11
  module CLI
@@ -15,33 +16,39 @@ module Belt
15
16
  include AppDetection
16
17
 
17
18
  def self.run(args)
19
+ name = FrontendRegistry.extract_flag!(args, '--name')
20
+ path = FrontendRegistry.extract_flag!(args, '--path')
18
21
  framework = args.shift
19
22
 
20
23
  if framework.nil? || !FRAMEWORKS.include?(framework)
21
- puts "Usage: belt generate frontend <#{FRAMEWORKS.join('|')}>"
24
+ puts "Usage: belt generate frontend <#{FRAMEWORKS.join('|')}> [--name NAME] [--path DIR]"
22
25
  puts "\nScaffolds a frontend application with build tooling and API client."
23
26
  puts "\nExamples:"
24
27
  puts ' belt generate frontend react'
25
28
  puts ' belt generate frontend vue'
29
+ puts ' belt generate frontend react --name ops --path ops-app'
26
30
  exit 1
27
31
  end
28
32
 
29
- new(framework).generate
33
+ new(framework, name: name, path: path).generate
30
34
  end
31
35
 
32
- def initialize(framework, quiet: false, announce: true)
36
+ def initialize(framework, quiet: false, announce: true, name: nil, path: nil)
33
37
  @framework = framework
34
38
  @quiet = quiet
35
39
  @announce = announce
36
40
  @app_name = detect_app_name
37
41
  @module_name = @app_name.split(/[-_]/).map(&:capitalize).join
42
+ @name = name || (path ? File.basename(path) : 'frontend')
43
+ @path = path || name || 'frontend'
44
+ @frontend = Frontend.new(name: @name, path: @path, default: @name == 'frontend' && @path == 'frontend')
38
45
  end
39
46
 
40
47
  def generate
41
- dest_dir = 'frontend'
48
+ dest_dir = @frontend.path
42
49
 
43
50
  if Dir.exist?(dest_dir) && !Dir.empty?(dest_dir)
44
- puts "Directory 'frontend/' already exists and is not empty."
51
+ puts "Directory '#{dest_dir}/' already exists and is not empty."
45
52
  exit 1
46
53
  end
47
54
 
@@ -54,8 +61,9 @@ module Belt
54
61
  end
55
62
 
56
63
  copy_template(framework_dir, dest_dir)
64
+ register_frontend!
57
65
 
58
- puts "\n✓ Frontend (#{@framework}) created in frontend/" unless @quiet
66
+ puts "\n✓ Frontend (#{@framework}) created in #{dest_dir}/" unless @quiet
59
67
 
60
68
  install_dependencies(dest_dir)
61
69
  setup_frontend_infra_for_existing_environments
@@ -64,7 +72,11 @@ module Belt
64
72
  return if @quiet || !@announce
65
73
 
66
74
  puts "\nNext steps:"
67
- puts ' belt server # Start local dev server'
75
+ if @frontend.name == 'frontend'
76
+ puts ' belt server # Start local dev server'
77
+ else
78
+ puts " belt server --frontend #{@frontend.name}"
79
+ end
68
80
  puts ' belt deploy # Deploy everything to AWS'
69
81
  end
70
82
 
@@ -88,9 +100,18 @@ module Belt
88
100
  end
89
101
  end
90
102
 
103
+ def register_frontend!
104
+ custom = @frontend.name != 'frontend' || @frontend.path != 'frontend'
105
+ config_exists = FrontendRegistry.find_config_path
106
+ return unless custom || config_exists
107
+
108
+ dest = FrontendRegistry.register!(name: @frontend.name, path: @frontend.path)
109
+ puts " update #{dest} (#{@frontend.name})" unless @quiet
110
+ end
111
+
91
112
  def setup_frontend_infra_for_existing_environments
92
113
  module_dir = 'infrastructure/modules/app'
93
- frontend_tf = File.join(module_dir, 'frontend.tf')
114
+ frontend_tf = File.join(module_dir, "#{@frontend.tf_name == 'frontend' ? 'frontend' : @frontend.tf_name}.tf")
94
115
 
95
116
  if File.exist?(frontend_tf)
96
117
  puts " skip #{frontend_tf} (already exists)" unless @quiet
@@ -101,7 +122,7 @@ module Belt
101
122
 
102
123
  puts "\n Setting up frontend infrastructure..." unless @quiet
103
124
  require_relative 'frontend_setup_command'
104
- FrontendSetupCommand.new(nil, quiet: true).run
125
+ FrontendSetupCommand.new(nil, quiet: true, frontend: @frontend).run
105
126
  puts " create #{frontend_tf}" unless @quiet
106
127
  end
107
128
 
@@ -118,7 +139,7 @@ module Belt
118
139
  require_relative 'views_command'
119
140
  resources.each do |resource_name|
120
141
  fields = ViewsCommand.read_schema_fields(resource_name)
121
- ViewsCommand.new(resource_name, fields, force: true).generate
142
+ ViewsCommand.new(resource_name, fields, force: true, frontend: @frontend).generate
122
143
  end
123
144
  end
124
145