belt 0.1.4 → 0.1.6

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: d40f2f591f9daeb3ee7a04cebbb5399268bb181771ccaeddb036ca100159dcfd
4
- data.tar.gz: 77a31c0cf454675261c2103cfc66ba391d879a6fdd24b7f7df2a05fdf7a3313c
3
+ metadata.gz: b09432f7426352033b67c6cc696bc90cddcae4b1facae08ce7b6fc08763b7eef
4
+ data.tar.gz: bb4617d6514bef6636b5b2efcfd32eaee6ebbc66b09a7e7f83581340c0fe5714
5
5
  SHA512:
6
- metadata.gz: b8e1a4a0bb6127fe6216f11f218b4ad9c835acf04b45b163c711ebec98dd9153016b8dc09136bfeeea1b34a6f83d45831e407aa5db831601872a5df1d303f0ad
7
- data.tar.gz: b8dc9e16e672b73b99afcae96d268c8ab8d7eac0fbd30b5a82bffcc6f34b6a4189b1c14af84b55d0d5d1458de088c91984a41ff9bd5e30b5264a9e24af3444f6
6
+ metadata.gz: 2c67896f36d6b09e394e19a73a92a6c8e31e9ec43071bf92c905f7d78ba260766d5b46aa2ee9d6c4d665c3313679557df527b866089bad240693fcdd1ce89e91
7
+ data.tar.gz: 8cf8956ea9a6227237be1dccc0cfe15b96ac9e75c3ddae543873cb067fcc5b8648fa23a1a258ccd3470afb9097ba54a7bc2dedfadcc701294b6ff8d9449ac755
@@ -155,6 +155,13 @@ module Belt
155
155
  end
156
156
  end
157
157
 
158
+ # Final fallback: gem-embedded controllers under Belt:: namespace
159
+ class_name = "#{controller_name.split(%r{[_/]}).map(&:capitalize).join}Controller"
160
+ belt_class_name = "Belt::#{class_name}"
161
+ if Belt.const_defined?(class_name, false)
162
+ return Belt.const_get(class_name, false)
163
+ end
164
+
158
165
  raise Belt::ActionNotFound, "Controller not found: #{controller_name}"
159
166
  end
160
167
 
Binary file
@@ -0,0 +1,112 @@
1
+ * { margin: 0; padding: 0; box-sizing: border-box; }
2
+ body {
3
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
4
+ background: #f8f9fa;
5
+ color: #2d3748;
6
+ min-height: 100vh;
7
+ }
8
+
9
+ /* Hero section — full width background image */
10
+ .hero {
11
+ position: relative;
12
+ width: 100%;
13
+ overflow: hidden;
14
+ }
15
+ .hero-bg {
16
+ width: 100%;
17
+ height: auto;
18
+ display: block;
19
+ }
20
+ .overlay {
21
+ position: absolute;
22
+ top: 35%;
23
+ left: 50%;
24
+ transform: translate(-50%, -50%);
25
+ padding: 2rem 3rem;
26
+ background: rgba(255, 255, 255, 0.50);
27
+ backdrop-filter: blur(6px);
28
+ -webkit-backdrop-filter: blur(6px);
29
+ border-radius: 12px;
30
+ text-align: center;
31
+ max-width: 600px;
32
+ width: 90%;
33
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
34
+ }
35
+ .overlay h1 {
36
+ font-size: 2.2rem;
37
+ font-weight: 700;
38
+ color: #2d3748;
39
+ margin-bottom: 0.4rem;
40
+ }
41
+ .overlay .subtitle {
42
+ font-size: 1.1rem;
43
+ color: #4a5568;
44
+ }
45
+
46
+ /* Content below the hero */
47
+ .container {
48
+ max-width: 700px;
49
+ margin: 0 auto;
50
+ padding: 2rem;
51
+ }
52
+ .stack-check {
53
+ display: flex;
54
+ align-items: center;
55
+ justify-content: center;
56
+ gap: 0.75rem;
57
+ margin-bottom: 2rem;
58
+ flex-wrap: wrap;
59
+ }
60
+ .check-item {
61
+ display: flex;
62
+ align-items: center;
63
+ gap: 0.4rem;
64
+ padding: 0.5rem 1rem;
65
+ border-radius: 8px;
66
+ font-weight: 500;
67
+ }
68
+ .check-pass {
69
+ background: rgba(39, 174, 96, 0.1);
70
+ border: 1px solid rgba(39, 174, 96, 0.3);
71
+ color: #1e8449;
72
+ }
73
+ .check-warn {
74
+ background: rgba(243, 156, 18, 0.1);
75
+ border: 1px solid rgba(243, 156, 18, 0.3);
76
+ color: #d68910;
77
+ }
78
+ .icon { font-size: 1.1rem; }
79
+ .arrow { color: #718096; font-size: 1.2rem; }
80
+ .info {
81
+ background: #ffffff;
82
+ border: 1px solid #e2e8f0;
83
+ border-radius: 12px;
84
+ padding: 1.5rem;
85
+ margin-bottom: 1.5rem;
86
+ }
87
+ .info p { margin-bottom: 0.5rem; color: #4a5568; }
88
+ .info p:last-child { margin-bottom: 0; }
89
+ .info strong { color: #2d3748; }
90
+ .info code {
91
+ background: rgba(99, 45, 145, 0.08);
92
+ padding: 0.15rem 0.4rem;
93
+ border-radius: 4px;
94
+ color: #632d91;
95
+ font-size: 0.85rem;
96
+ }
97
+ .next-steps {
98
+ background: #ffffff;
99
+ border: 1px solid #e2e8f0;
100
+ border-radius: 12px;
101
+ padding: 1.5rem;
102
+ }
103
+ .next-steps h2 { font-size: 1.1rem; margin-bottom: 1rem; color: #2d3748; }
104
+ .next-steps ol { padding-left: 1.5rem; }
105
+ .next-steps li { margin-bottom: 0.6rem; color: #4a5568; line-height: 1.5; }
106
+ .next-steps code {
107
+ background: rgba(99, 45, 145, 0.08);
108
+ padding: 0.15rem 0.4rem;
109
+ border-radius: 4px;
110
+ color: #632d91;
111
+ font-size: 0.85rem;
112
+ }
@@ -3,7 +3,9 @@
3
3
  module Belt
4
4
  module CLI
5
5
  module AppDetection
6
- def detect_app_name
6
+ # Detects the primary namespace from the route definitions.
7
+ # Used by generators to determine controller directory and route file naming.
8
+ def detect_namespace
7
9
  routes_file = 'infrastructure/routes.tf.rb'
8
10
  if File.exist?(routes_file)
9
11
  match = File.read(routes_file).match(/namespace :(\w+)/)
@@ -12,6 +14,20 @@ module Belt
12
14
  File.basename(Dir.pwd)
13
15
  end
14
16
 
17
+ # Detects the application name.
18
+ # Prefers terraform.tfvars app_name, then falls back to directory name.
19
+ def detect_app_name
20
+ # Check any environment tfvars for the app_name
21
+ Dir.glob('infrastructure/*/terraform.tfvars').each do |f|
22
+ content = File.read(f)
23
+ match = content.match(/app_name\s*=\s*"([^"]+)"/)
24
+ return match[1] if match
25
+ end
26
+
27
+ # Fall back to directory name
28
+ File.basename(Dir.pwd)
29
+ end
30
+
15
31
  # S3 bucket names follow DNS rules — underscores are not allowed.
16
32
  # App names often use underscores (Ruby namespaces); convert for S3.
17
33
  def s3_safe_name(name)
@@ -0,0 +1,495 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'json'
5
+ require 'open3'
6
+ require 'tmpdir'
7
+ require_relative 'env_resolver'
8
+ require_relative 'terraform_command'
9
+
10
+ module Belt
11
+ module CLI
12
+ class DeployCommand
13
+ def self.run(args)
14
+ # Handle `belt deploy frontend <env>` as before
15
+ if args.first == 'frontend'
16
+ args.shift
17
+ Belt::CLI::FrontendDeployCommand.run(args)
18
+ return
19
+ end
20
+
21
+ auto_approve = false
22
+ rebuild = false
23
+ filtered_args = []
24
+
25
+ args.each do |arg|
26
+ case arg
27
+ when '--auto', '--yes', '-y'
28
+ auto_approve = true
29
+ when '--rebuild'
30
+ rebuild = true
31
+ when '-h', '--help'
32
+ puts help_text
33
+ exit 0
34
+ else
35
+ filtered_args << arg
36
+ end
37
+ end
38
+
39
+ env = EnvResolver.resolve(filtered_args)
40
+
41
+ # Default to the first available environment when none specified
42
+ if env.nil?
43
+ available = TerraformCommand.list_environments
44
+ if available.any?
45
+ env = available.first
46
+ else
47
+ puts 'Usage: belt deploy [environment] [options]'
48
+ puts "\nDefaults to BELT_ENV or the first available environment."
49
+ puts "\nOptions:"
50
+ puts ' --auto, --yes, -y Skip confirmation prompt (auto-approve)'
51
+ puts ' --rebuild Rebuild and push Lambda code directly (bypasses Terraform)'
52
+ puts ' -h, --help Show this help'
53
+ puts "\nExamples:"
54
+ puts ' belt deploy # Deploy dev (or BELT_ENV)'
55
+ puts ' belt deploy prod # Deploy to prod'
56
+ puts ' belt deploy dev --auto # Deploy without confirmation'
57
+ puts ' belt deploy --rebuild # Fast code push (no infra changes)'
58
+ puts ' belt deploy frontend dev # Deploy frontend only'
59
+ puts "\nNo environments found. Run `belt generate environment dev` first."
60
+ exit 1
61
+ end
62
+ end
63
+
64
+ if rebuild
65
+ new(env, auto_approve: auto_approve, extra_args: filtered_args).run_rebuild
66
+ else
67
+ new(env, auto_approve: auto_approve, extra_args: filtered_args).run
68
+ end
69
+ end
70
+
71
+ def self.help_text
72
+ <<~HELP
73
+ Deploy your Belt application to AWS.
74
+
75
+ Usage: belt deploy [environment] [options]
76
+ belt deploy frontend <environment>
77
+
78
+ This runs the full deployment lifecycle:
79
+ 1. Ensure Gemfile.lock is consistent (fix stale PATH refs)
80
+ 2. terraform init (initialize providers/modules)
81
+ 3. terraform plan (preview changes)
82
+ 4. Prompt for confirmation (unless --auto)
83
+ 5. terraform apply (deploy changes)
84
+
85
+ Options:
86
+ --auto, --yes, -y Skip confirmation prompt (auto-approve)
87
+ --rebuild Rebuild and push Lambda code directly (bypasses Terraform).
88
+ Much faster for code-only changes — packages gems via Docker,
89
+ zips, and pushes with `aws lambda update-function-code`.
90
+ -h, --help Show this help
91
+
92
+ Environment:
93
+ Defaults to BELT_ENV if set, otherwise the first available environment.
94
+
95
+ Examples:
96
+ belt deploy # Deploy dev (or BELT_ENV)
97
+ belt deploy prod # Deploy to prod
98
+ belt deploy dev --auto # Deploy without confirmation (CI mode)
99
+ belt deploy --rebuild # Fast code push to dev (no infra changes)
100
+ belt deploy prod --rebuild # Fast code push to prod
101
+ belt deploy frontend dev # Deploy frontend assets only
102
+ HELP
103
+ end
104
+
105
+ def initialize(env, auto_approve: false, extra_args: [])
106
+ @env = env
107
+ @auto_approve = auto_approve
108
+ @extra_args = extra_args
109
+ @infra_dir = TerraformCommand.find_infrastructure_dir
110
+ @project_root = find_project_root
111
+ end
112
+
113
+ def run
114
+ validate!
115
+ env_dir = File.join(@infra_dir, @env)
116
+
117
+ puts "belt → deploying #{@env} (in #{env_dir}/)\n\n"
118
+
119
+ ensure_lockfile_consistent!
120
+
121
+ Dir.chdir(env_dir) do
122
+ run_init
123
+ run_plan
124
+ return unless confirm_apply
125
+ run_apply
126
+ end
127
+
128
+ puts "\n✅ Deployed #{@env} successfully!"
129
+ print_outputs(env_dir)
130
+ puts "\n Run `belt server` to view your app locally (auto-connects to the deployed API)."
131
+ end
132
+
133
+ def run_rebuild
134
+ validate!
135
+ validate_aws!
136
+
137
+ puts "belt → rebuilding Lambda for #{@env}\n\n"
138
+
139
+ lambda_dir = find_lambda_dir
140
+ abort 'Error: Cannot find lambda/ directory.' unless lambda_dir
141
+
142
+ function_name = find_lambda_function_name
143
+ abort 'Error: Cannot determine Lambda function name from Terraform state.' unless function_name
144
+
145
+ ensure_lockfile_consistent!
146
+ generate_routes(lambda_dir)
147
+ build_and_deploy(lambda_dir, function_name)
148
+ end
149
+
150
+ private
151
+
152
+ def find_project_root
153
+ # Walk up from infra dir to find project root (where Gemfile lives)
154
+ dir = @infra_dir ? File.dirname(@infra_dir) : Dir.pwd
155
+ while dir != '/'
156
+ return dir if File.exist?(File.join(dir, 'Gemfile'))
157
+ dir = File.dirname(dir)
158
+ end
159
+ Dir.pwd
160
+ end
161
+
162
+ def find_lambda_dir
163
+ candidates = [
164
+ File.join(@project_root, 'lambda'),
165
+ File.join(Dir.pwd, 'lambda')
166
+ ]
167
+ candidates.find { |d| Dir.exist?(d) }
168
+ end
169
+
170
+ def validate!
171
+ unless @infra_dir
172
+ abort "Error: No infrastructure/ directory found.\n" \
173
+ "Run `belt generate environment #{@env}` first."
174
+ end
175
+
176
+ env_dir = File.join(@infra_dir, @env)
177
+ return if Dir.exist?(env_dir)
178
+
179
+ abort "Error: Environment '#{@env}' not found at #{env_dir}/.\n\n" \
180
+ "Available environments:\n#{TerraformCommand.list_environments.map { |e| " #{e}" }.join("\n")}\n\n" \
181
+ "Create it with: belt generate environment #{@env}"
182
+ end
183
+
184
+ def validate_aws!
185
+ stdout, status = Open3.capture2('aws', 'sts', 'get-caller-identity', '--output', 'json')
186
+ unless status.success?
187
+ abort "Error: AWS credentials not available.\n" \
188
+ "Run `aws sso login` or configure AWS_PROFILE."
189
+ end
190
+ @aws_account = JSON.parse(stdout)['Account'] rescue nil
191
+ end
192
+
193
+ # Ensure Gemfile.lock doesn't have stale PATH references that will break
194
+ # the Docker-based gem build. If the Gemfile no longer uses `path:` but the
195
+ # lockfile still references a PATH source, refresh the lock.
196
+ def ensure_lockfile_consistent!
197
+ gemfile = File.join(@project_root, 'Gemfile')
198
+ lockfile = File.join(@project_root, 'Gemfile.lock')
199
+ return unless File.exist?(gemfile) && File.exist?(lockfile)
200
+
201
+ gemfile_content = File.read(gemfile)
202
+ lockfile_content = File.read(lockfile)
203
+
204
+ # Find gems that are PATH-sourced in the lockfile but NOT path-sourced in the Gemfile
205
+ stale_path_gems = detect_stale_path_gems(gemfile_content, lockfile_content)
206
+ return if stale_path_gems.empty?
207
+
208
+ puts " 🔧 Fixing stale Gemfile.lock (#{stale_path_gems.join(', ')} referenced as PATH but Gemfile uses RubyGems)"
209
+ Dir.chdir(@project_root) do
210
+ success = system('bundle', 'lock', '--update', *stale_path_gems)
211
+ unless success
212
+ puts " ⚠ `bundle lock --update #{stale_path_gems.join(' ')}` failed — trying full bundle update"
213
+ system('bundle', 'update', *stale_path_gems)
214
+ end
215
+ end
216
+ puts ''
217
+ end
218
+
219
+ def detect_stale_path_gems(gemfile_content, lockfile_content)
220
+ stale = []
221
+
222
+ # Parse PATH sections from lockfile
223
+ # Format:
224
+ # PATH
225
+ # remote: ../something
226
+ # specs:
227
+ # gem_name (version)
228
+ lockfile_content.scan(/^PATH\n\s+remote:.*?\n\s+specs:\n((?:\s{4}\S.*\n?)+)/m).each do |match|
229
+ match[0].scan(/^\s{4}(\S+)\s/).each do |gem_match|
230
+ gem_name = gem_match[0]
231
+ # Check if the Gemfile still uses path: for this gem
232
+ # Match patterns like: gem 'name', path: ... or gem "name", path: ...
233
+ has_path_in_gemfile = gemfile_content.match?(/gem\s+['"]#{Regexp.escape(gem_name)}['"].*path:/)
234
+ stale << gem_name unless has_path_in_gemfile
235
+ end
236
+ end
237
+
238
+ stale
239
+ end
240
+
241
+ def find_lambda_function_name
242
+ env_dir = File.join(@infra_dir, @env)
243
+ return nil unless Dir.exist?(File.join(env_dir, '.terraform'))
244
+
245
+ # Get function name from terraform state
246
+ Dir.chdir(env_dir) do
247
+ output, status = Open3.capture2('terraform', 'output', '-json')
248
+ return nil unless status.success?
249
+
250
+ data = JSON.parse(output) rescue {}
251
+
252
+ # Try lambda_functions output first
253
+ if data['lambda_functions']
254
+ funcs = data['lambda_functions']['value']
255
+ if funcs.is_a?(Hash) && funcs.any?
256
+ return funcs.values.first # ARN — we need just the function name
257
+ end
258
+ end
259
+
260
+ # Fallback: parse from terraform state directly
261
+ state_output, state_status = Open3.capture2('terraform', 'state', 'show', 'conveyor_belt.main')
262
+ return nil unless state_status.success?
263
+
264
+ # Look for lambda function names in state
265
+ state_output.match(/function_name\s*=\s*"([^"]+)"/)&.captures&.first
266
+ end
267
+ end
268
+
269
+ def generate_routes(lambda_dir)
270
+ routes_dir = File.join(lambda_dir, 'lib', 'routes')
271
+ return unless Dir.exist?(routes_dir)
272
+
273
+ puts ' 🗺️ Regenerating route manifests...'
274
+ success = system('belt', 'routes', '--namespace', 'all', '--output-dir', routes_dir)
275
+ if success
276
+ puts ' ✅ Routes updated'
277
+ else
278
+ puts ' ⚠ Route generation failed — using existing manifests'
279
+ end
280
+ puts ''
281
+ end
282
+
283
+ def build_and_deploy(lambda_dir, function_identifier)
284
+ # Extract function name from ARN if needed
285
+ function_name = if function_identifier.include?(':')
286
+ function_identifier.split(':').last
287
+ else
288
+ function_identifier
289
+ end
290
+
291
+ build_dir = Dir.mktmpdir('belt-rebuild-')
292
+
293
+ begin
294
+ puts ' 📦 Building Lambda package...'
295
+ assemble_package(lambda_dir, build_dir)
296
+ build_gems(build_dir)
297
+ zip_file = create_zip(build_dir)
298
+
299
+ puts ' 🚀 Deploying to AWS...'
300
+ deploy_to_lambda(function_name, zip_file)
301
+
302
+ puts "\n✅ Lambda #{function_name} rebuilt and deployed!"
303
+ puts ''
304
+ puts ' 📋 View logs:'
305
+ puts " aws logs tail /aws/lambda/#{function_name} --follow"
306
+ ensure
307
+ FileUtils.rm_rf(build_dir)
308
+ end
309
+ end
310
+
311
+ def assemble_package(lambda_dir, build_dir)
312
+ # Copy handler file(s) — .rb files at root of lambda/
313
+ Dir.glob(File.join(lambda_dir, '*.rb')).each do |f|
314
+ FileUtils.cp(f, build_dir)
315
+ end
316
+
317
+ # Copy shared directories
318
+ %w[controllers models lib helpers templates serializers jobs config].each do |dir|
319
+ src = File.join(lambda_dir, dir)
320
+ FileUtils.cp_r(src, File.join(build_dir, dir)) if Dir.exist?(src)
321
+ end
322
+
323
+ # Copy Gemfile from project root
324
+ gemfile = File.join(@project_root, 'Gemfile')
325
+ lockfile = File.join(@project_root, 'Gemfile.lock')
326
+ FileUtils.cp(gemfile, build_dir) if File.exist?(gemfile)
327
+ FileUtils.cp(lockfile, build_dir) if File.exist?(lockfile)
328
+
329
+ puts ' 📁 Copied handlers, controllers, models, lib, config'
330
+ end
331
+
332
+ def build_gems(build_dir)
333
+ puts ' 🐳 Building gems in Docker (Lambda-compatible)...'
334
+
335
+ docker_cmd = [
336
+ 'docker', 'run', '--rm',
337
+ '--platform', 'linux/amd64',
338
+ '-v', "#{build_dir}:/var/task",
339
+ '-w', '/var/task',
340
+ 'public.ecr.aws/sam/build-ruby3.4:latest-x86_64',
341
+ '/bin/bash', '-c',
342
+ "bundle config set --local path 'vendor/bundle' && " \
343
+ "bundle config set --local without 'development test' && " \
344
+ 'bundle config set silence_root_warning 1 && ' \
345
+ 'bundle install --jobs 4 && ' \
346
+ 'bundle clean --force'
347
+ ]
348
+
349
+ output, status = Open3.capture2e(*docker_cmd)
350
+ unless status.success?
351
+ puts output
352
+ abort "\n✗ Gem build failed. Check Docker is running and the Gemfile is valid."
353
+ end
354
+
355
+ # Strip fat from vendor bundle
356
+ strip_vendor_fat(build_dir)
357
+ puts ' ✅ Gems installed'
358
+ end
359
+
360
+ def strip_vendor_fat(build_dir)
361
+ vendor_dir = File.join(build_dir, 'vendor')
362
+ return unless Dir.exist?(vendor_dir)
363
+
364
+ # Remove docs, tests, and non-runtime files from gems
365
+ Dir.glob(File.join(vendor_dir, 'bundle/ruby/*/gems/*/')).each do |gem_dir|
366
+ %w[spec test tests doc docs examples benchmarks].each do |fat_dir|
367
+ FileUtils.rm_rf(File.join(gem_dir, fat_dir))
368
+ end
369
+ end
370
+
371
+ # Remove non-essential files
372
+ exts = %w[*.md *.rdoc *.txt *.c *.h *.o Makefile *.log CHANGELOG* HISTORY* LICENSE* README* .gitignore .travis.yml .rubocop.yml Rakefile]
373
+ exts.each do |pattern|
374
+ Dir.glob(File.join(vendor_dir, "**", pattern)).each do |f|
375
+ File.delete(f) if File.file?(f)
376
+ end
377
+ end
378
+
379
+ # Strip debug symbols from native extensions
380
+ Dir.glob(File.join(vendor_dir, '**/*.so')).each do |so_file|
381
+ system('strip', '--strip-debug', so_file, err: File::NULL, out: File::NULL)
382
+ end
383
+ end
384
+
385
+ def create_zip(build_dir)
386
+ zip_file = File.join(Dir.tmpdir, "belt-lambda-#{@env}-#{Time.now.to_i}.zip")
387
+
388
+ Dir.chdir(build_dir) do
389
+ output, status = Open3.capture2e('zip', '-qr', zip_file, '.')
390
+ unless status.success?
391
+ puts output
392
+ abort "\n✗ Failed to create ZIP"
393
+ end
394
+ end
395
+
396
+ size_mb = (File.size(zip_file) / 1024.0 / 1024.0).round(1)
397
+ puts " 🗜️ Package: #{size_mb}MB"
398
+ zip_file
399
+ end
400
+
401
+ def deploy_to_lambda(function_name, zip_file)
402
+ _output, status = Open3.capture2(
403
+ 'aws', 'lambda', 'update-function-code',
404
+ '--function-name', function_name,
405
+ '--zip-file', "fileb://#{zip_file}",
406
+ '--output', 'json'
407
+ )
408
+
409
+ File.delete(zip_file) if File.exist?(zip_file)
410
+
411
+ unless status.success?
412
+ abort "\n✗ Failed to update Lambda function code.\n" \
413
+ " Function: #{function_name}\n" \
414
+ " Check that the function exists and you have permissions."
415
+ end
416
+
417
+ # Wait for update to complete
418
+ print ' Waiting for Lambda to be ready...'
419
+ 30.times do
420
+ sleep 1
421
+ state_output, = Open3.capture2(
422
+ 'aws', 'lambda', 'get-function',
423
+ '--function-name', function_name,
424
+ '--query', 'Configuration.LastUpdateStatus',
425
+ '--output', 'text'
426
+ )
427
+ case state_output.strip
428
+ when 'Successful'
429
+ puts ' ✅'
430
+ return
431
+ when 'Failed'
432
+ puts ' ✗'
433
+ abort ' Lambda update failed. Check CloudWatch logs.'
434
+ end
435
+ print '.'
436
+ end
437
+ puts ' (timed out waiting, but code was pushed)'
438
+ end
439
+
440
+ def run_init
441
+ puts '━━━ terraform init ━━━'
442
+ success = system('terraform', 'init')
443
+ abort "\n✗ terraform init failed" unless success
444
+ puts ''
445
+ end
446
+
447
+ def run_plan
448
+ puts '━━━ terraform plan ━━━'
449
+ success = system('terraform', 'plan', '-out=tfplan', *@extra_args)
450
+ abort "\n✗ terraform plan failed" unless success
451
+ puts ''
452
+ end
453
+
454
+ def confirm_apply
455
+ return true if @auto_approve
456
+
457
+ if @env == 'prod' || @env == 'production'
458
+ print "⚠️ You are about to deploy to \e[1;31m#{@env}\e[0m. Continue? [y/N] "
459
+ else
460
+ print "Apply these changes to #{@env}? [y/N] "
461
+ end
462
+
463
+ response = $stdin.gets
464
+ return true if response&.strip&.downcase&.start_with?('y')
465
+
466
+ puts 'Cancelled.'
467
+ cleanup_plan
468
+ false
469
+ end
470
+
471
+ def run_apply
472
+ puts '━━━ terraform apply ━━━'
473
+ success = system('terraform', 'apply', 'tfplan')
474
+ cleanup_plan
475
+ abort "\n✗ terraform apply failed" unless success
476
+ end
477
+
478
+ def cleanup_plan
479
+ File.delete('tfplan') if File.exist?('tfplan')
480
+ end
481
+
482
+ def print_outputs(env_dir)
483
+ Dir.chdir(env_dir) do
484
+ output = `terraform output 2>/dev/null`.strip
485
+ return if output.empty?
486
+
487
+ puts "\nOutputs:"
488
+ output.each_line do |line|
489
+ puts " #{line}"
490
+ end
491
+ end
492
+ end
493
+ end
494
+ end
495
+ end