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
@@ -0,0 +1,395 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'erb'
5
+ require_relative 'app_detection'
6
+ require_relative 'frontend_registry'
7
+
8
+ module Belt
9
+ module CLI
10
+ class AuthCommand
11
+ TEMPLATE_DIR = File.expand_path('../../templates/generate/auth', __dir__)
12
+ MODULE_DIR = 'infrastructure/modules/app'
13
+
14
+ include AppDetection
15
+
16
+ def self.run(args)
17
+ if args.include?('--help') || args.include?('-h')
18
+ print_help
19
+ exit 0
20
+ end
21
+
22
+ force = args.delete('--force') || args.delete('-f')
23
+ signup = args.delete('--signup')
24
+ frontend_name = FrontendRegistry.extract_flag!(args, '--frontend')
25
+ pools = parse_pools(args)
26
+
27
+ new(pools: pools, force: force, signup: signup, frontend_name: frontend_name).generate
28
+ end
29
+
30
+ def self.destroy(_args)
31
+ new(pools: [], force: false, skip_frontend_resolve: true).remove
32
+ end
33
+
34
+ def self.print_help
35
+ puts <<~HELP
36
+ Generate Cognito user pool infrastructure for authentication.
37
+
38
+ Usage: belt generate auth [pool_names...] [options]
39
+
40
+ Options:
41
+ --signup Allow public user registration (generates frontend views)
42
+ --frontend NAME Target frontend when several exist
43
+ --force, -f Overwrite existing cognito.tf (skip collision check)
44
+
45
+ Arguments:
46
+ pool_names Optional pool names for multiple user pools.
47
+ If omitted, generates a single pool named "main".
48
+
49
+ Examples:
50
+ belt g auth # Admin-only, single pool
51
+ belt g auth --signup # Public signup with frontend views
52
+ belt g auth web # Named pool: "web"
53
+ belt g auth web mobile # Two pools
54
+ belt g auth --force # Overwrite existing
55
+
56
+ What this generates:
57
+ infrastructure/modules/app/cognito.tf User pool + client resources
58
+ infrastructure/modules/app/cognito_outputs.tf Pool ID, ARN, and client ID outputs
59
+
60
+ With --signup (when frontend/ exists):
61
+ frontend/src/lib/auth.js Auth module (signIn, signUp, etc.)
62
+ frontend/src/lib/apiClient.js API client with Authorization header
63
+ frontend/src/pages/auth/Login.jsx Login page
64
+ frontend/src/pages/auth/SignUp.jsx Registration page
65
+ frontend/src/pages/auth/ConfirmEmail.jsx Email verification page
66
+ frontend/src/components/ProtectedRoute.jsx Route guard component
67
+
68
+ Without --signup (admin-only, default):
69
+ frontend/src/lib/auth.js Auth module (signIn only)
70
+ frontend/src/lib/apiClient.js API client with Authorization header
71
+ frontend/src/pages/auth/Login.jsx Login page
72
+ frontend/src/components/ProtectedRoute.jsx Route guard component
73
+
74
+ It also patches:
75
+ infrastructure/modules/app/main.tf Adds cognito_user_pool_arns to conveyor_belt
76
+
77
+ After running:
78
+ 1. Review the generated Cognito config in cognito.tf
79
+ 2. Add auth: :cognito to your routes namespace
80
+ 3. Run `belt deploy` to create the user pool
81
+ 4. Create your account (admin-only): aws cognito-idp admin-create-user ...
82
+ HELP
83
+ end
84
+
85
+ # Parse pool names from args. Default to ["main"] if none provided.
86
+ def self.parse_pools(args)
87
+ names = args.reject { |a| a.start_with?('-') }
88
+ names = ['main'] if names.empty?
89
+ names.map(&:downcase).map { |n| n.gsub(/[^a-z0-9_]/, '_') }
90
+ end
91
+
92
+ def initialize(pools:, force: false, signup: false, frontend_name: nil, skip_frontend_resolve: false)
93
+ @pool_names = pools
94
+ @force = force
95
+ @signup = signup
96
+ @app_name = detect_app_name
97
+ @pools = build_pool_metadata
98
+ @frontend = skip_frontend_resolve ? nil : resolve_frontend(frontend_name)
99
+ end
100
+
101
+ def generate
102
+ check_collision! unless @force
103
+ ensure_module_dir!
104
+
105
+ write_cognito_tf
106
+ write_cognito_outputs_tf
107
+ patch_main_tf
108
+ patch_env_outputs
109
+ generate_frontend_auth if frontend?
110
+
111
+ puts "\nβœ“ Auth generated!"
112
+ print_next_steps
113
+ end
114
+
115
+ def print_next_steps
116
+ puts "\nNext steps:"
117
+ puts ' 1. Add auth: :cognito to your routes namespace:'
118
+ puts ''
119
+ puts ' namespace :api, auth: :cognito do'
120
+ puts ' # your resources...'
121
+ puts ' end'
122
+ puts ''
123
+ if frontend?
124
+ puts " 2. Wire auth into your #{@frontend.src_dir}/App.jsx:"
125
+ puts ''
126
+ puts " import Login from './pages/auth/Login'"
127
+ puts " import ProtectedRoute from './components/ProtectedRoute'"
128
+ puts ''
129
+ puts ' // Add login route:'
130
+ puts ' <Route path="/login" element={<Login onLogin={() => window.location.href = \'/\'} />} />'
131
+ puts ''
132
+ puts ' // Wrap protected routes:'
133
+ puts ' <Route path="/*" element={<ProtectedRoute><YourApp /></ProtectedRoute>} />'
134
+ puts ''
135
+ puts ' 3. Deploy: belt deploy'
136
+ print_create_user_step(4) unless @signup
137
+ else
138
+ puts ' 2. Deploy: belt deploy'
139
+ print_create_user_step(3) unless @signup
140
+ end
141
+ end
142
+
143
+ def print_create_user_step(step_num)
144
+ puts " #{step_num}. Create your account:"
145
+ puts ' aws cognito-idp admin-create-user \\'
146
+ puts ' --user-pool-id <pool-id-from-terraform-output> \\'
147
+ puts ' --username your@email.com \\'
148
+ puts ' --temporary-password TempPass123 \\'
149
+ puts ' --message-action SUPPRESS'
150
+ end
151
+
152
+ def remove
153
+ removed = []
154
+
155
+ cognito_tf = File.join(MODULE_DIR, 'cognito.tf')
156
+ cognito_outputs_tf = File.join(MODULE_DIR, 'cognito_outputs.tf')
157
+
158
+ if File.exist?(cognito_tf)
159
+ FileUtils.rm(cognito_tf)
160
+ removed << cognito_tf
161
+ puts " remove #{cognito_tf}"
162
+ end
163
+
164
+ if File.exist?(cognito_outputs_tf)
165
+ FileUtils.rm(cognito_outputs_tf)
166
+ removed << cognito_outputs_tf
167
+ puts " remove #{cognito_outputs_tf}"
168
+ end
169
+
170
+ unpatch_main_tf
171
+ removed << File.join(MODULE_DIR, 'main.tf') if @main_tf_patched
172
+
173
+ if removed.empty?
174
+ puts ' Nothing to remove β€” auth was not generated.'
175
+ else
176
+ puts "\nβœ“ Auth destroyed!"
177
+ end
178
+ end
179
+
180
+ private
181
+
182
+ def build_pool_metadata
183
+ if @pool_names.length == 1 && @pool_names.first == 'main'
184
+ [{ name: 'main', suffix: '', label: '' }]
185
+ else
186
+ @pool_names.map do |name|
187
+ { name: name, suffix: "-#{name}", label: " (#{name})" }
188
+ end
189
+ end
190
+ end
191
+
192
+ def resolve_frontend(name)
193
+ registry = FrontendRegistry.new
194
+ return nil if registry.empty? && name.nil?
195
+
196
+ return registry.resolve!(name) if name
197
+
198
+ registry.default
199
+ end
200
+
201
+ def frontend?
202
+ @frontend && Dir.exist?(@frontend.src_dir)
203
+ end
204
+
205
+ def generate_frontend_auth
206
+ frontend_template_dir = File.join(TEMPLATE_DIR, 'frontend')
207
+ src = @frontend.src_dir
208
+
209
+ # Generate auth lib files
210
+ lib_dir = File.join(src, 'lib')
211
+ FileUtils.mkdir_p(lib_dir)
212
+ copy_frontend_file(frontend_template_dir, 'auth.js', File.join(lib_dir, 'auth.js'))
213
+ copy_frontend_file(frontend_template_dir, 'apiClient.js', File.join(lib_dir, 'apiClient.js'))
214
+
215
+ pages_dir = File.join(src, 'pages/auth')
216
+ FileUtils.mkdir_p(pages_dir)
217
+ copy_frontend_file(frontend_template_dir, 'Login.jsx', File.join(pages_dir, 'Login.jsx'))
218
+ copy_frontend_file(frontend_template_dir, 'auth.css', File.join(pages_dir, 'auth.css'))
219
+ if @signup
220
+ # Generate auth pages
221
+ copy_frontend_file(frontend_template_dir, 'SignUp.jsx', File.join(pages_dir, 'SignUp.jsx'))
222
+ copy_frontend_file(frontend_template_dir, 'ConfirmEmail.jsx', File.join(pages_dir, 'ConfirmEmail.jsx'))
223
+ end
224
+
225
+ # Generate ProtectedRoute component
226
+ components_dir = File.join(src, 'components')
227
+ FileUtils.mkdir_p(components_dir)
228
+ copy_frontend_file(frontend_template_dir, 'ProtectedRoute.jsx',
229
+ File.join(components_dir, 'ProtectedRoute.jsx'))
230
+
231
+ install_cognito_sdk
232
+ end
233
+
234
+ def copy_frontend_file(template_dir, filename, dest)
235
+ src = File.join(template_dir, filename)
236
+ FileUtils.cp(src, dest)
237
+ puts " create #{dest}"
238
+ end
239
+
240
+ def install_cognito_sdk
241
+ puts "\n Installing amazon-cognito-identity-js..."
242
+ success = system('npm', 'install', 'amazon-cognito-identity-js',
243
+ '--prefix', @frontend.path, '--no-fund', '--no-audit', '--silent')
244
+ if success
245
+ puts ' βœ“ npm dependency installed'
246
+ else
247
+ puts " ⚠ npm install failed β€” run: cd #{@frontend.path} && " \
248
+ 'npm install amazon-cognito-identity-js'
249
+ end
250
+ end
251
+
252
+ def check_collision!
253
+ cognito_tf = File.join(MODULE_DIR, 'cognito.tf')
254
+ return unless File.exist?(cognito_tf)
255
+
256
+ puts "\nβœ— Auth already exists at #{cognito_tf}"
257
+ puts "\nTo overwrite, run again with --force:"
258
+ puts " belt g auth #{@pool_names.join(' ')} --force"
259
+ exit 1
260
+ end
261
+
262
+ def ensure_module_dir!
263
+ FileUtils.mkdir_p(MODULE_DIR)
264
+ end
265
+
266
+ def write_cognito_tf
267
+ dest = File.join(MODULE_DIR, 'cognito.tf')
268
+ write_template('cognito.tf.erb', dest)
269
+ puts " create #{dest}"
270
+ end
271
+
272
+ def write_cognito_outputs_tf
273
+ dest = File.join(MODULE_DIR, 'cognito_outputs.tf')
274
+ write_template('cognito_outputs.tf.erb', dest)
275
+ puts " create #{dest}"
276
+ end
277
+
278
+ def patch_main_tf
279
+ main_tf = File.join(MODULE_DIR, 'main.tf')
280
+ return unless File.exist?(main_tf)
281
+
282
+ content = File.read(main_tf)
283
+
284
+ if content.include?('cognito_user_pool_arns')
285
+ puts " skip #{main_tf} (cognito_user_pool_arns already present)"
286
+ return
287
+ end
288
+
289
+ return unless content.match?(/^resource "conveyor_belt"/)
290
+
291
+ arns = @pools.map { |p| "aws_cognito_user_pool.#{p[:name]}.arn" }
292
+ arn_value = build_arn_value(arns)
293
+
294
+ insert_cognito_into_resource(content, main_tf, arn_value)
295
+ end
296
+
297
+ def patch_env_outputs
298
+ env_dirs = Dir.glob('infrastructure/*/outputs.tf')
299
+ .reject { |f| f.include?('modules') }
300
+
301
+ env_dirs.each do |outputs_file|
302
+ content = File.read(outputs_file)
303
+
304
+ next if content.include?('cognito_user_pool_id')
305
+
306
+ cognito_outputs = @pools.map do |pool|
307
+ suffix = pool[:suffix]
308
+ <<~HCL
309
+
310
+ output "cognito_user_pool_id#{suffix}" {
311
+ description = "Cognito User Pool ID#{pool[:label]}"
312
+ value = module.app.cognito_user_pool_id#{suffix}
313
+ }
314
+
315
+ output "cognito_client_id#{suffix}" {
316
+ description = "Cognito User Pool Client ID#{pool[:label]}"
317
+ value = module.app.cognito_client_id#{suffix}
318
+ }
319
+
320
+ output "cognito_region" {
321
+ description = "AWS region for Cognito"
322
+ value = var.aws_region
323
+ }
324
+ HCL
325
+ end.join
326
+
327
+ File.write(outputs_file, content + cognito_outputs)
328
+ env_name = File.basename(File.dirname(outputs_file))
329
+ puts " update #{outputs_file} (added cognito outputs for #{env_name})"
330
+ end
331
+ end
332
+
333
+ def build_arn_value(arns)
334
+ if arns.length == 1
335
+ "[#{arns.first}]"
336
+ else
337
+ "[\n #{arns.join(",\n ")}\n ]"
338
+ end
339
+ end
340
+
341
+ def insert_cognito_into_resource(content, main_tf, arn_value)
342
+ lines = content.lines
343
+ brace_depth = 0
344
+ insert_index = nil
345
+ in_resource = false
346
+
347
+ lines.each_with_index do |line, idx|
348
+ if line.match?(/^resource "conveyor_belt"/)
349
+ in_resource = true
350
+ brace_depth = 0
351
+ end
352
+
353
+ next unless in_resource
354
+
355
+ brace_depth += line.count('{') - line.count('}')
356
+
357
+ next unless brace_depth <= 0
358
+
359
+ insert_index = idx
360
+ break
361
+ end
362
+
363
+ return unless insert_index
364
+
365
+ cognito_line = "\n cognito_user_pool_arns = #{arn_value}\n"
366
+ lines.insert(insert_index, cognito_line)
367
+ File.write(main_tf, lines.join)
368
+ puts " update #{main_tf} (added cognito_user_pool_arns)"
369
+ end
370
+
371
+ def unpatch_main_tf
372
+ main_tf = File.join(MODULE_DIR, 'main.tf')
373
+ @main_tf_patched = false
374
+ return unless File.exist?(main_tf)
375
+
376
+ content = File.read(main_tf)
377
+ return unless content.include?('cognito_user_pool_arns')
378
+
379
+ # Remove the cognito_user_pool_arns line(s) β€” could be single or multi-line
380
+ updated = content.gsub(/\n\s*cognito_user_pool_arns\s*=\s*\[[^\]]*\]\n/, "\n")
381
+ return if updated == content
382
+
383
+ File.write(main_tf, updated)
384
+ puts " update #{main_tf} (removed cognito_user_pool_arns)"
385
+ @main_tf_patched = true
386
+ end
387
+
388
+ def write_template(template_name, dest_path)
389
+ template_path = File.join(TEMPLATE_DIR, template_name)
390
+ content = ERB.new(File.read(template_path), trim_mode: '-').result(binding)
391
+ File.write(dest_path, content)
392
+ end
393
+ end
394
+ end
395
+ end
@@ -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
@@ -10,6 +10,7 @@ require_relative 'backup_config'
10
10
  require_relative 'backup_runner'
11
11
  require_relative 'environment_config'
12
12
  require_relative 'path_gem_materializer'
13
+ require_relative 'zip_artifact_builder'
13
14
 
14
15
  module Belt
15
16
  module CLI
@@ -66,7 +67,8 @@ module Belt
66
67
  puts ' belt deploy prod # Deploy to prod'
67
68
  puts ' belt deploy dev --auto # Deploy without confirmation'
68
69
  puts ' belt deploy --rebuild # Fast code push (no infra changes)'
69
- puts ' belt deploy frontend dev # Deploy frontend only'
70
+ puts ' belt deploy frontend dev # Deploy frontend(s)'
71
+ puts ' belt deploy frontend dev --frontend ops'
70
72
  puts "\nNo environments found. Run `belt generate environment dev` first."
71
73
  exit 1
72
74
  end
@@ -92,10 +94,11 @@ module Belt
92
94
  1. Ensure Gemfile.lock is consistent (fix stale PATH refs)
93
95
  2. Regenerate route manifests
94
96
  3. Run pre-deploy backups (if configured in belt.rb)
95
- 4. terraform init (initialize providers/modules)
96
- 5. terraform plan (preview changes)
97
- 6. Prompt for confirmation (unless --auto)
98
- 7. terraform apply (deploy changes)
97
+ 4. Build sidecar Lambda zips Terraform needs at plan time
98
+ 5. terraform init (initialize providers/modules)
99
+ 6. terraform plan (preview changes)
100
+ 7. Prompt for confirmation (unless --auto)
101
+ 8. terraform apply (deploy changes)
99
102
 
100
103
  Options:
101
104
  --auto, --yes, -y Skip confirmation prompt (auto-approve)
@@ -130,7 +133,8 @@ module Belt
130
133
  belt deploy prod --rebuild # Fast code push to prod
131
134
  belt deploy prod --skip-backup # Skip backups for this deploy
132
135
  belt deploy prod --backup-only # Run backups without deploying
133
- belt deploy frontend dev # Deploy frontend assets only
136
+ belt deploy frontend dev # Deploy frontend asset(s)
137
+ belt deploy frontend dev --frontend ops
134
138
  HELP
135
139
  end
136
140
 
@@ -145,9 +149,9 @@ module Belt
145
149
 
146
150
  def run
147
151
  validate!
148
- env_dir = File.join(@infra_dir, @env)
149
-
150
152
  load_and_apply_env_config!
153
+ run_preflight_checks!
154
+ env_dir = File.join(@infra_dir, @env)
151
155
 
152
156
  puts "belt β†’ deploying #{@env} (in #{env_dir}/)\n\n"
153
157
 
@@ -155,6 +159,7 @@ module Belt
155
159
  warn_active_path_gems!
156
160
  generate_routes_if_needed
157
161
  run_backups unless @skip_backup
162
+ build_zip_artifacts!
158
163
 
159
164
  Dir.chdir(env_dir) do
160
165
  run_init
@@ -263,6 +268,14 @@ module Belt
263
268
  "Create it with: belt generate environment #{@env}"
264
269
  end
265
270
 
271
+ def run_preflight_checks!
272
+ require_relative 'doctor_command'
273
+ doctor = DoctorCommand.new(preflight: true)
274
+ return if doctor.run
275
+
276
+ abort 'Deploy blocked. Fix the issues above, then try again.'
277
+ end
278
+
266
279
  def validate_aws!
267
280
  stdout, status = Open3.capture2('aws', 'sts', 'get-caller-identity', '--output', 'json')
268
281
  unless status.success?
@@ -308,6 +321,19 @@ module Belt
308
321
  return match[1] if match
309
322
  end
310
323
 
324
+ # Try variables.tf for a default app_name value
325
+ vars_file = File.join(@infra_dir, @env, 'variables.tf')
326
+ if File.exist?(vars_file)
327
+ content = File.read(vars_file)
328
+ if content.match?(/variable\s+"app_name"/)
329
+ block_match = content.match(/variable\s+"app_name"\s*\{([^}]*)\}/m)
330
+ if block_match
331
+ default_match = block_match[1].match(/default\s*=\s*"([^"]+)"/)
332
+ return default_match[1] if default_match
333
+ end
334
+ end
335
+ end
336
+
311
337
  # Fall back to directory name
312
338
  File.basename(@project_root)
313
339
  end
@@ -402,7 +428,7 @@ module Belt
402
428
 
403
429
  def generate_routes(lambda_dir)
404
430
  routes_dir = File.join(lambda_dir, 'lib', 'routes')
405
- return unless Dir.exist?(routes_dir)
431
+ FileUtils.mkdir_p(routes_dir)
406
432
 
407
433
  puts ' πŸ—ΊοΈ Regenerating route manifests...'
408
434
  success = system('belt', 'routes', '--namespace', 'all', '--output-dir', routes_dir)
@@ -655,6 +681,10 @@ module Belt
655
681
  puts ' (timed out waiting, but code was pushed)'
656
682
  end
657
683
 
684
+ def build_zip_artifacts!
685
+ ZipArtifactBuilder.build!(project_root: @project_root, infra_dir: @infra_dir)
686
+ end
687
+
658
688
  def run_init
659
689
  puts '━━━ terraform init ━━━'
660
690
  success = system('terraform', 'init')
@@ -698,15 +728,19 @@ module Belt
698
728
  end
699
729
 
700
730
  def deploy_frontend_if_exists
701
- frontend_dir = File.join(@project_root, 'frontend')
702
- return unless Dir.exist?(frontend_dir) && File.exist?(File.join(frontend_dir, 'package.json'))
703
-
704
- puts "\n━━━ frontend deploy ━━━"
731
+ require_relative 'frontend_registry'
705
732
  require_relative 'frontend_deploy_command'
706
- Belt::CLI::FrontendDeployCommand.new(@env).run
707
- rescue StandardError => e
708
- puts "\n ⚠ Frontend deploy failed: #{e.message}"
709
- puts " Run `belt deploy frontend #{@env}` manually to retry."
733
+
734
+ frontends = FrontendRegistry.new.existing
735
+ return if frontends.empty?
736
+
737
+ frontends.each do |frontend|
738
+ puts "\n━━━ #{frontend.label} deploy ━━━"
739
+ Belt::CLI::FrontendDeployCommand.new(@env, frontend: frontend).run
740
+ rescue StandardError => e
741
+ puts "\n ⚠ #{frontend.label.capitalize} deploy failed: #{e.message}"
742
+ puts " Run `belt deploy frontend #{@env} --frontend #{frontend.name}` manually to retry."
743
+ end
710
744
  end
711
745
 
712
746
  def print_outputs(env_dir)