belt 0.2.18 → 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: b5e83d99ec1a6a02931418e3ade22cb51b291028884529e25abbd74f7c23a41f
4
- data.tar.gz: 938cc1a121a468de4dcb838c04fa44b7a01b78e5165a5da5f805cb330c88d59d
3
+ metadata.gz: 983c166381b409ee19c2a23c1439be35180892299af76570097d206dea1ba1a4
4
+ data.tar.gz: f7888dca2b10805bed099da525b968b90e511c1b8381990ba85ab80dc23a9263
5
5
  SHA512:
6
- metadata.gz: d581ff695fda0a3288f809d0c52805091dd9de73a69af5b21f4675a0f65f3d04431f72e18db3f099f6299a3a9f5b0045323218a60a54982d58312021c9bcedbd
7
- data.tar.gz: 59927af373dde4560edbaede9b9d8f81f43b229804479d0ff874ea67193b1a97bcf67e51afc388f66b3359fc7105df1ac0b2aef61a166d29fbcd43d509881004
6
+ metadata.gz: 253f76decf02f960c12011b95570b04213c33800eecfaf70415071f8a2792cae2480a71251cc0ec1b565b837b1c9229f6522c6452124f63560334c6cb6b838d4
7
+ data.tar.gz: 5393e1c8cdef68b50dee5bca41a299d02560ecc9e65718e8ed45e2cb09b5ffabaf74eefc11b47e59f35fd7b78b602b866db7028eae82246e0da76b14910eca53
@@ -19,9 +19,10 @@ module Belt
19
19
  end
20
20
 
21
21
  force = args.delete('--force') || args.delete('-f')
22
+ signup = args.delete('--signup')
22
23
  pools = parse_pools(args)
23
24
 
24
- new(pools: pools, force: force).generate
25
+ new(pools: pools, force: force, signup: signup).generate
25
26
  end
26
27
 
27
28
  def self.destroy(_args)
@@ -35,6 +36,7 @@ module Belt
35
36
  Usage: belt generate auth [pool_names...] [options]
36
37
 
37
38
  Options:
39
+ --signup Allow public user registration (generates frontend views)
38
40
  --force, -f Overwrite existing cognito.tf (skip collision check)
39
41
 
40
42
  Arguments:
@@ -42,26 +44,38 @@ module Belt
42
44
  If omitted, generates a single pool named "main".
43
45
 
44
46
  Examples:
45
- belt g auth # Single pool: "main"
46
- belt g auth web # Single pool: "web"
47
- belt g auth web mobile # Two pools: "web" and "mobile"
48
- belt g auth web android ios # Three pools: "web", "android", "ios"
47
+ belt g auth # Admin-only, single pool
48
+ belt g auth --signup # Public signup with frontend views
49
+ belt g auth web # Named pool: "web"
50
+ belt g auth web mobile # Two pools
49
51
  belt g auth --force # Overwrite existing
50
52
 
51
53
  What this generates:
52
54
  infrastructure/modules/app/cognito.tf User pool + client resources
53
55
  infrastructure/modules/app/cognito_outputs.tf Pool ID, ARN, and client ID outputs
54
56
 
57
+ With --signup (when frontend/ exists):
58
+ frontend/src/lib/auth.js Auth module (signIn, signUp, etc.)
59
+ frontend/src/lib/apiClient.js API client with Authorization header
60
+ frontend/src/pages/auth/Login.jsx Login page
61
+ frontend/src/pages/auth/SignUp.jsx Registration page
62
+ frontend/src/pages/auth/ConfirmEmail.jsx Email verification page
63
+ frontend/src/components/ProtectedRoute.jsx Route guard component
64
+
65
+ Without --signup (admin-only, default):
66
+ frontend/src/lib/auth.js Auth module (signIn only)
67
+ frontend/src/lib/apiClient.js API client with Authorization header
68
+ frontend/src/pages/auth/Login.jsx Login page
69
+ frontend/src/components/ProtectedRoute.jsx Route guard component
70
+
55
71
  It also patches:
56
72
  infrastructure/modules/app/main.tf Adds cognito_user_pool_arns to conveyor_belt
57
73
 
58
74
  After running:
59
75
  1. Review the generated Cognito config in cognito.tf
60
- 2. Customize password policy, MFA, triggers as needed
61
- 3. Run `belt apply <env>` to deploy
62
-
63
- To remove:
64
- belt destroy auth
76
+ 2. Add auth: :cognito to your routes namespace
77
+ 3. Run `belt deploy` to create the user pool
78
+ 4. Create your account (admin-only): aws cognito-idp admin-create-user ...
65
79
  HELP
66
80
  end
67
81
 
@@ -72,9 +86,10 @@ module Belt
72
86
  names.map(&:downcase).map { |n| n.gsub(/[^a-z0-9_]/, '_') }
73
87
  end
74
88
 
75
- def initialize(pools:, force: false)
89
+ def initialize(pools:, force: false, signup: false)
76
90
  @pool_names = pools
77
91
  @force = force
92
+ @signup = signup
78
93
  @app_name = detect_app_name
79
94
  @pools = build_pool_metadata
80
95
  end
@@ -86,12 +101,48 @@ module Belt
86
101
  write_cognito_tf
87
102
  write_cognito_outputs_tf
88
103
  patch_main_tf
104
+ patch_env_outputs
105
+ generate_frontend_auth if frontend?
89
106
 
90
107
  puts "\n✓ Auth generated!"
108
+ print_next_steps
109
+ end
110
+
111
+ def print_next_steps
91
112
  puts "\nNext steps:"
92
- puts ' 1. Review infrastructure/modules/app/cognito.tf'
93
- puts ' 2. Customize password policy, MFA, or Lambda triggers as needed'
94
- puts ' 3. Run `belt apply <env>` to deploy'
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'
95
146
  end
96
147
 
97
148
  def remove
@@ -134,6 +185,55 @@ module Belt
134
185
  end
135
186
  end
136
187
 
188
+ def frontend?
189
+ Dir.exist?('frontend/src')
190
+ end
191
+
192
+ def generate_frontend_auth
193
+ frontend_template_dir = File.join(TEMPLATE_DIR, 'frontend')
194
+
195
+ # Generate auth lib files
196
+ lib_dir = 'frontend/src/lib'
197
+ FileUtils.mkdir_p(lib_dir)
198
+ copy_frontend_file(frontend_template_dir, 'auth.js', File.join(lib_dir, 'auth.js'))
199
+ copy_frontend_file(frontend_template_dir, 'apiClient.js', File.join(lib_dir, 'apiClient.js'))
200
+
201
+ pages_dir = 'frontend/src/pages/auth'
202
+ FileUtils.mkdir_p(pages_dir)
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'))
205
+ if @signup
206
+ # Generate auth pages
207
+ copy_frontend_file(frontend_template_dir, 'SignUp.jsx', File.join(pages_dir, 'SignUp.jsx'))
208
+ copy_frontend_file(frontend_template_dir, 'ConfirmEmail.jsx', File.join(pages_dir, 'ConfirmEmail.jsx'))
209
+ end
210
+
211
+ # Generate ProtectedRoute component
212
+ components_dir = 'frontend/src/components'
213
+ FileUtils.mkdir_p(components_dir)
214
+ copy_frontend_file(frontend_template_dir, 'ProtectedRoute.jsx',
215
+ File.join(components_dir, 'ProtectedRoute.jsx'))
216
+
217
+ install_cognito_sdk
218
+ end
219
+
220
+ def copy_frontend_file(template_dir, filename, dest)
221
+ src = File.join(template_dir, filename)
222
+ FileUtils.cp(src, dest)
223
+ puts " create #{dest}"
224
+ end
225
+
226
+ def install_cognito_sdk
227
+ puts "\n Installing @aws-sdk/client-cognito-identity-provider..."
228
+ success = system('npm', 'install', '@aws-sdk/client-cognito-identity-provider',
229
+ '--prefix', 'frontend', '--no-fund', '--no-audit', '--silent')
230
+ if success
231
+ puts ' ✓ npm dependency installed'
232
+ else
233
+ puts ' ⚠ npm install failed — run: cd frontend && npm install @aws-sdk/client-cognito-identity-provider'
234
+ end
235
+ end
236
+
137
237
  def check_collision!
138
238
  cognito_tf = File.join(MODULE_DIR, 'cognito.tf')
139
239
  return unless File.exist?(cognito_tf)
@@ -179,6 +279,42 @@ module Belt
179
279
  insert_cognito_into_resource(content, main_tf, arn_value)
180
280
  end
181
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
+
182
318
  def build_arn_value(arns)
183
319
  if arns.length == 1
184
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