belt 0.2.11 → 0.2.13

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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -1
  3. data/lib/belt/cli/app_detection.rb +19 -5
  4. data/lib/belt/cli/contracts_command.rb +142 -0
  5. data/lib/belt/cli/destroy_command.rb +74 -0
  6. data/lib/belt/cli/frontend_command.rb +23 -0
  7. data/lib/belt/cli/generate_command.rb +11 -9
  8. data/lib/belt/cli/logs_command.rb +634 -0
  9. data/lib/belt/cli/new_command.rb +2 -2
  10. data/lib/belt/cli/routes_command/schema_loader.rb +17 -7
  11. data/lib/belt/cli/routes_command.rb +7 -3
  12. data/lib/belt/cli/setup_command.rb +1 -1
  13. data/lib/belt/cli/views_command.rb +96 -11
  14. data/lib/belt/cli.rb +6 -0
  15. data/lib/belt/root.rb +12 -3
  16. data/lib/belt/route_dsl.rb +9 -6
  17. data/lib/belt/version.rb +1 -1
  18. data/lib/belt.rb +1 -0
  19. data/lib/templates/frontend_infra/frontend.tf.erb +2 -1
  20. data/lib/templates/generate/controller.rb.erb +13 -18
  21. data/lib/templates/generate/model.rb.erb +1 -14
  22. data/lib/templates/module/frontend.tf.erb +2 -1
  23. data/lib/templates/module/main.tf.erb +1 -1
  24. data/lib/templates/new_app/AGENTS.md.erb +4 -4
  25. data/lib/templates/new_app/Gemfile.erb +0 -2
  26. data/lib/templates/new_app/README.md.erb +2 -2
  27. data/lib/templates/new_app/gitignore.erb +3 -0
  28. data/lib/templates/new_app/lambda/api.rb.erb +0 -6
  29. data/lib/templates/new_app/lambda/config/environment.rb.erb +0 -6
  30. data/lib/templates/new_app/lambda/models/application_record.rb.erb +0 -2
  31. data/lib/templates/plugin/AGENTS.md.erb +1 -1
  32. data/lib/templates/plugin/README.md.erb +1 -1
  33. metadata +11 -3
  34. /data/lib/templates/new_app/config/{schema.tf.rb.erb → contracts.rb.erb} +0 -0
  35. /data/lib/templates/new_app/config/{routes.tf.rb.erb → routes.rb.erb} +0 -0
@@ -0,0 +1,634 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'open3'
5
+
6
+ module Belt
7
+ module CLI
8
+ class LogsCommand
9
+ include AppDetection
10
+
11
+ COLORS = {
12
+ red: "\e[0;31m",
13
+ green: "\e[0;32m",
14
+ yellow: "\e[0;33m",
15
+ blue: "\e[0;34m",
16
+ magenta: "\e[0;35m",
17
+ cyan: "\e[0;36m",
18
+ gray: "\e[0;90m",
19
+ bold: "\e[1m",
20
+ dim: "\e[2m",
21
+ reset: "\e[0m"
22
+ }.freeze
23
+
24
+ def self.run(args)
25
+ if args.include?('--help') || args.include?('-h')
26
+ puts usage
27
+ exit 0
28
+ end
29
+
30
+ new(args).run
31
+ end
32
+
33
+ def self.usage
34
+ <<~USAGE
35
+ Usage: belt logs [lambda] [options]
36
+
37
+ View Lambda function logs. Without arguments, tails all Lambdas for the current environment.
38
+
39
+ Arguments:
40
+ lambda Lambda function short name (e.g., api, worker). Optional — shows all if omitted.
41
+
42
+ Options:
43
+ -e, --env ENV Environment (default: BELT_ENV or first detected)
44
+ -f, --follow Follow logs in real-time
45
+ -s, --since PERIOD Time range (default: 5m). Examples: 5m, 30m, 1h, 2h
46
+ -l, --level LEVEL Minimum log level: DEBUG, INFO, WARN, ERROR (default: INFO)
47
+ --error Show only the most recent error and exit
48
+ --raw Show raw JSON logs without formatting
49
+ --no-color Disable colorized output
50
+ -h, --help Show this help
51
+
52
+ Examples:
53
+ belt logs # Last 5m of all lambdas (current env)
54
+ belt logs api # Last 5m of api lambda
55
+ belt logs api -f # Follow api lambda logs
56
+ belt logs -e prod # Last 5m of all lambdas in prod
57
+ belt logs api -s 30m # Last 30 minutes
58
+ belt logs --error # Show most recent error across all lambdas
59
+ belt logs api --error # Show most recent error for api lambda
60
+ USAGE
61
+ end
62
+
63
+ def initialize(args)
64
+ @lambda_name = nil
65
+ @env = nil
66
+ @follow = false
67
+ @since = '5m'
68
+ @level = 'INFO'
69
+ @error_mode = false
70
+ @raw = false
71
+ @color = $stdout.tty?
72
+ parse_args(args)
73
+ end
74
+
75
+ def run
76
+ @env ||= detect_environment
77
+ abort 'Error: Cannot determine environment. Pass -e ENV or set BELT_ENV.' unless @env
78
+
79
+ @app_name = detect_app_name
80
+ abort 'Error: Cannot determine app name.' unless @app_name
81
+
82
+ if @lambda_name
83
+ tail_single(@lambda_name)
84
+ else
85
+ tail_all
86
+ end
87
+ end
88
+
89
+ private
90
+
91
+ def parse_args(args)
92
+ i = 0
93
+ while i < args.length
94
+ case args[i]
95
+ when '-e', '--env'
96
+ @env = args[i + 1]
97
+ i += 2
98
+ when '-f', '--follow'
99
+ @follow = true
100
+ i += 1
101
+ when '-s', '--since'
102
+ @since = args[i + 1]
103
+ i += 2
104
+ when '-l', '--level'
105
+ @level = args[i + 1]&.upcase
106
+ i += 2
107
+ when '--error'
108
+ @error_mode = true
109
+ i += 1
110
+ when '--raw'
111
+ @raw = true
112
+ i += 1
113
+ when '--no-color'
114
+ @color = false
115
+ i += 1
116
+ else
117
+ @lambda_name = args[i] unless args[i].start_with?('-')
118
+ i += 1
119
+ end
120
+ end
121
+ end
122
+
123
+ def detect_environment
124
+ ENV.fetch('BELT_ENV', nil) || detect_environments.first
125
+ end
126
+
127
+ def tail_single(lambda_name)
128
+ log_group = log_group_for(lambda_name)
129
+
130
+ unless log_group_exists?(log_group)
131
+ abort "#{c(:red)}✗ No log group found: #{log_group}#{c(:reset)}\n " \
132
+ 'The Lambda may not have been deployed yet.'
133
+ end
134
+
135
+ if @error_mode
136
+ find_recent_error(log_group, lambda_name)
137
+ elsif @follow
138
+ follow_logs(log_group, lambda_name)
139
+ else
140
+ fetch_historical(log_group, lambda_name)
141
+ end
142
+ end
143
+
144
+ def tail_all
145
+ lambdas = discover_lambda_names
146
+ if lambdas.empty?
147
+ abort "#{c(:red)}✗ No Lambda functions found for #{@app_name}-#{@env}#{c(:reset)}\n " \
148
+ "Deploy with `belt deploy #{@env}` first, or specify a lambda name: `belt logs api`"
149
+ end
150
+
151
+ if @error_mode
152
+ find_errors_across(lambdas)
153
+ elsif @follow
154
+ follow_all(lambdas)
155
+ else
156
+ fetch_all_historical(lambdas)
157
+ end
158
+ end
159
+
160
+ def discover_lambda_names
161
+ names = lambda_names_from_terraform
162
+ return names if names.any?
163
+
164
+ lambda_names_from_log_groups
165
+ end
166
+
167
+ def lambda_names_from_terraform
168
+ infra_dir = find_infra_dir
169
+ env_dir = infra_dir ? File.join(infra_dir, @env) : nil
170
+ return [] unless env_dir && Dir.exist?(File.join(env_dir, '.terraform'))
171
+
172
+ Dir.chdir(env_dir) do
173
+ output, status = Open3.capture2('terraform', 'output', '-json')
174
+ return [] unless status.success?
175
+
176
+ data = begin
177
+ JSON.parse(output)
178
+ rescue JSON::ParserError
179
+ {}
180
+ end
181
+
182
+ if data['lambda_functions']
183
+ funcs = data['lambda_functions']['value']
184
+ if funcs.is_a?(Hash)
185
+ return funcs.keys
186
+ elsif funcs.is_a?(Array)
187
+ return funcs.map { |f| f.is_a?(String) ? f.split('-').last : nil }.compact
188
+ end
189
+ end
190
+
191
+ data.keys.grep(/_function_name$/).map { |k| data[k]['value']&.split('-')&.last }.compact
192
+ end
193
+ rescue StandardError
194
+ []
195
+ end
196
+
197
+ def lambda_names_from_log_groups
198
+ prefix = "/aws/lambda/#{@app_name}-#{@env}-"
199
+ output, status = Open3.capture2(
200
+ 'aws', 'logs', 'describe-log-groups',
201
+ '--log-group-name-prefix', prefix,
202
+ '--query', 'logGroups[].logGroupName',
203
+ '--output', 'json'
204
+ )
205
+ return [] unless status.success?
206
+
207
+ groups = begin
208
+ JSON.parse(output)
209
+ rescue JSON::ParserError
210
+ []
211
+ end
212
+ groups.map { |g| g.sub(prefix, '') }
213
+ end
214
+
215
+ def log_group_for(lambda_name)
216
+ "/aws/lambda/#{@app_name}-#{@env}-#{lambda_name}"
217
+ end
218
+
219
+ def log_group_exists?(log_group)
220
+ _, status = Open3.capture2(
221
+ 'aws', 'logs', 'describe-log-groups',
222
+ '--log-group-name-prefix', log_group,
223
+ '--query', "logGroups[?logGroupName=='#{log_group}'].logGroupName",
224
+ '--output', 'text'
225
+ )
226
+ status.success?
227
+ end
228
+
229
+ def follow_logs(log_group, lambda_name)
230
+ print_header(lambda_name)
231
+ puts "#{c(:yellow)}Following logs (Ctrl+C to stop)...#{c(:reset)}\n\n"
232
+
233
+ cmd = ['aws', 'logs', 'tail', log_group, '--follow', '--format', 'short']
234
+ IO.popen(cmd, err: %i[child out]) do |io|
235
+ io.each_line { |line| process_tail_line(line) }
236
+ end
237
+ rescue Interrupt
238
+ puts "\n#{c(:dim)}Stopped.#{c(:reset)}"
239
+ end
240
+
241
+ def follow_all(lambdas)
242
+ puts "#{c(:cyan)}═══════════════════════════════════════════════════════════════#{c(:reset)}"
243
+ puts "#{c(:bold)}Lambda Logs: #{c(:magenta)}#{@app_name}-#{@env}#{c(:reset)} (#{lambdas.join(', ')})"
244
+ puts "#{c(:cyan)}═══════════════════════════════════════════════════════════════#{c(:reset)}"
245
+ puts "#{c(:yellow)}Following logs (Ctrl+C to stop)...#{c(:reset)}\n\n"
246
+
247
+ threads = lambdas.map do |name|
248
+ log_group = log_group_for(name)
249
+ Thread.new do
250
+ cmd = ['aws', 'logs', 'tail', log_group, '--follow', '--format', 'short']
251
+ IO.popen(cmd, err: %i[child out]) do |io|
252
+ io.each_line { |line| process_tail_line(line, prefix: name) }
253
+ end
254
+ rescue StandardError
255
+ nil
256
+ end
257
+ end
258
+
259
+ threads.each(&:join)
260
+ rescue Interrupt
261
+ puts "\n#{c(:dim)}Stopped.#{c(:reset)}"
262
+ end
263
+
264
+ def fetch_historical(log_group, lambda_name)
265
+ print_header(lambda_name)
266
+ puts "#{c(:dim)}Fetching last #{@since} of logs...#{c(:reset)}\n\n"
267
+
268
+ events = fetch_log_events(log_group)
269
+ if events.empty?
270
+ puts "#{c(:yellow)}No logs found in the last #{@since}#{c(:reset)}"
271
+ return
272
+ end
273
+
274
+ puts "#{c(:dim)}Found #{events.length} log events#{c(:reset)}\n\n"
275
+ events.each { |event| process_event(event) }
276
+
277
+ puts "\n#{c(:cyan)}═══════════════════════════════════════════════════════════════#{c(:reset)}"
278
+ puts "#{c(:dim)}Tip: Use -f to follow logs in real-time#{c(:reset)}"
279
+ end
280
+
281
+ def fetch_all_historical(lambdas)
282
+ puts "#{c(:cyan)}═══════════════════════════════════════════════════════════════#{c(:reset)}"
283
+ puts "#{c(:bold)}Lambda Logs: #{c(:magenta)}#{@app_name}-#{@env}#{c(:reset)} (#{lambdas.join(', ')})"
284
+ puts "#{c(:cyan)}═══════════════════════════════════════════════════════════════#{c(:reset)}"
285
+ puts "#{c(:dim)}Fetching last #{@since} of logs...#{c(:reset)}\n\n"
286
+
287
+ all_events = []
288
+ lambdas.each do |name|
289
+ log_group = log_group_for(name)
290
+ events = fetch_log_events(log_group)
291
+ events.each { |e| e['_lambda'] = name }
292
+ all_events.concat(events)
293
+ end
294
+
295
+ all_events.sort_by! { |e| e['timestamp'] || 0 }
296
+
297
+ if all_events.empty?
298
+ puts "#{c(:yellow)}No logs found in the last #{@since}#{c(:reset)}"
299
+ return
300
+ end
301
+
302
+ puts "#{c(:dim)}Found #{all_events.length} log events#{c(:reset)}\n\n"
303
+ all_events.each { |event| process_event(event, prefix: event['_lambda']) }
304
+
305
+ puts "\n#{c(:cyan)}═══════════════════════════════════════════════════════════════#{c(:reset)}"
306
+ puts "#{c(:dim)}Tip: Use -f to follow logs in real-time#{c(:reset)}"
307
+ end
308
+
309
+ def find_recent_error(log_group, lambda_name)
310
+ events = fetch_log_events(log_group, since: '30m', limit: 500)
311
+ error = find_error_in_events(events)
312
+
313
+ if error
314
+ puts "#{c(:bold)}Most recent error in #{c(:magenta)}#{lambda_name}#{c(:reset)}:\n\n"
315
+ format_error_event(error)
316
+ else
317
+ puts "#{c(:green)}✓ No errors found in the last 30 minutes for #{lambda_name}#{c(:reset)}"
318
+ end
319
+ end
320
+
321
+ def find_errors_across(lambdas)
322
+ latest_error = nil
323
+ latest_lambda = nil
324
+
325
+ lambdas.each do |name|
326
+ log_group = log_group_for(name)
327
+ events = fetch_log_events(log_group, since: '30m', limit: 500)
328
+ error = find_error_in_events(events)
329
+ next unless error
330
+
331
+ if latest_error.nil? || (error['timestamp'] || 0) > (latest_error['timestamp'] || 0)
332
+ latest_error = error
333
+ latest_lambda = name
334
+ end
335
+ end
336
+
337
+ if latest_error
338
+ puts "#{c(:bold)}Most recent error in #{c(:magenta)}#{latest_lambda}#{c(:reset)}:\n\n"
339
+ format_error_event(latest_error)
340
+ else
341
+ puts "#{c(:green)}✓ No errors found in the last 30 minutes#{c(:reset)}"
342
+ end
343
+ end
344
+
345
+ def find_error_in_events(events)
346
+ events.reverse_each do |event|
347
+ msg = event['message'] || ''
348
+ json = parse_json(msg)
349
+ next unless json
350
+
351
+ return event if json['errorMessage'] && json['errorType'] && json['stackTrace']
352
+ return event if json['level'] == 'ERROR'
353
+ return event if json['status_code'].to_i >= 500
354
+ end
355
+ nil
356
+ end
357
+
358
+ def format_error_event(event)
359
+ msg = event['message'] || ''
360
+ json = parse_json(msg)
361
+ return puts(msg) unless json
362
+
363
+ timestamp = format_timestamp(event['timestamp'])
364
+
365
+ if json['errorMessage'] && json['errorType']
366
+ format_init_error(json, timestamp)
367
+ else
368
+ format_structured_log(json, timestamp)
369
+ end
370
+ end
371
+
372
+ def format_init_error(json, timestamp)
373
+ puts "#{c(:gray)}#{timestamp}#{c(:reset)} #{c(:red)}#{c(:bold)}INIT ERROR#{c(:reset)}"
374
+ puts " #{c(:dim)}Type:#{c(:reset)} #{c(:red)}#{json['errorType']}#{c(:reset)}"
375
+ puts " #{c(:dim)}Message:#{c(:reset)} #{json['errorMessage']}"
376
+ return unless json['stackTrace'].is_a?(Array)
377
+
378
+ puts " #{c(:dim)}Stack:#{c(:reset)}"
379
+ json['stackTrace'].first(15).each do |line|
380
+ if line.match?(%r{(controllers|models|lib|helpers)/})
381
+ puts " #{c(:yellow)}→#{c(:reset)} #{line}"
382
+ elsif line.include?('/var/task/')
383
+ puts " #{c(:cyan)}→#{c(:reset)} #{line}"
384
+ else
385
+ puts " #{c(:gray)} #{line}#{c(:reset)}"
386
+ end
387
+ end
388
+ end
389
+
390
+ def fetch_log_events(log_group, since: @since, limit: 1000)
391
+ duration_ms = parse_since(since)
392
+ start_time = (Time.now.to_i * 1000) - duration_ms
393
+
394
+ output, status = Open3.capture2(
395
+ 'aws', 'logs', 'filter-log-events',
396
+ '--log-group-name', log_group,
397
+ '--start-time', start_time.to_s,
398
+ '--limit', limit.to_s,
399
+ '--output', 'json'
400
+ )
401
+ return [] unless status.success?
402
+
403
+ data = begin
404
+ JSON.parse(output)
405
+ rescue JSON::ParserError
406
+ {}
407
+ end
408
+ data['events'] || []
409
+ end
410
+
411
+ def process_tail_line(line, prefix: nil)
412
+ return if line.match?(/\b(START|END|REPORT|INIT_START|INIT_REPORT)\s/)
413
+ return if line.include?('[LambdaLoadout') || line.include?('"_aws":')
414
+
415
+ if line.include?('Critical exception from handler')
416
+ prefix_str = prefix ? "#{c(:blue)}[#{prefix}]#{c(:reset)} " : ''
417
+ puts "#{prefix_str}#{c(:red)}#{c(:bold)}CRITICAL EXCEPTION#{c(:reset)}"
418
+ return
419
+ end
420
+
421
+ json_part = line.sub(/\A[\dT:.+\-Z ]+\s*/, '').strip
422
+ json = parse_json(json_part)
423
+
424
+ if json
425
+ if json['errorMessage'] && json['errorType'] && json['stackTrace']
426
+ format_aws_error(json, prefix: prefix)
427
+ elsif @raw
428
+ puts json_part
429
+ else
430
+ timestamp = extract_time_from_line(line)
431
+ format_structured_log(json, timestamp, prefix: prefix)
432
+ end
433
+ elsif line.strip.length.positive?
434
+ prefix_str = prefix ? "#{c(:blue)}[#{prefix}]#{c(:reset)} " : ''
435
+ puts "#{prefix_str}#{c(:gray)}#{line.strip}#{c(:reset)}"
436
+ end
437
+ end
438
+
439
+ def process_event(event, prefix: nil)
440
+ msg = event['message'] || ''
441
+
442
+ return if msg.match?(/\A(START|END|REPORT|INIT_START|INIT_REPORT)\s/)
443
+ return if msg.include?('[LambdaLoadout') || msg.include?('"_aws":')
444
+
445
+ if msg.include?('Critical exception from handler')
446
+ prefix_str = prefix ? "#{c(:blue)}[#{prefix}]#{c(:reset)} " : ''
447
+ puts "#{prefix_str}#{c(:red)}#{c(:bold)}CRITICAL EXCEPTION#{c(:reset)}"
448
+ return
449
+ end
450
+
451
+ json = parse_json(msg)
452
+ timestamp = format_timestamp(event['timestamp'])
453
+
454
+ if json
455
+ if json['errorMessage'] && json['errorType'] && json['stackTrace']
456
+ format_aws_error(json, prefix: prefix)
457
+ elsif @raw
458
+ puts JSON.pretty_generate(json)
459
+ else
460
+ format_structured_log(json, timestamp, prefix: prefix)
461
+ end
462
+ elsif msg.strip.length.positive?
463
+ prefix_str = prefix ? "#{c(:blue)}[#{prefix}]#{c(:reset)} " : ''
464
+ puts "#{prefix_str}#{c(:gray)}#{msg.strip}#{c(:reset)}"
465
+ end
466
+ end
467
+
468
+ def format_structured_log(json, timestamp, prefix: nil)
469
+ level = json['level']
470
+ message = json['message'] || ''
471
+
472
+ return unless should_show_level?(level)
473
+
474
+ prefix_str = prefix ? "#{c(:blue)}[#{prefix}]#{c(:reset)} " : ''
475
+
476
+ case level
477
+ when 'ERROR'
478
+ format_error_log(json, message, timestamp, prefix_str)
479
+ when 'WARN'
480
+ format_warn_log(json, message, timestamp, prefix_str)
481
+ else
482
+ format_info_log(json, message, timestamp, prefix_str)
483
+ end
484
+ end
485
+
486
+ def format_error_log(json, message, timestamp, prefix_str)
487
+ puts "#{prefix_str}#{c(:gray)}#{timestamp}#{c(:reset)} " \
488
+ "#{c(:red)}ERROR#{c(:reset)} #{c(:bold)}#{message}#{c(:reset)}"
489
+ puts " #{c(:dim)}Action:#{c(:reset)} #{json['action']}" if json['action']
490
+ if json['error_class']
491
+ puts " #{c(:dim)}Error:#{c(:reset)} " \
492
+ "#{c(:red)}#{json['error_class']}#{c(:reset)}: #{json['error_message']}"
493
+ end
494
+ puts " #{c(:dim)}Path:#{c(:reset)} #{json['path']}" if json['path']
495
+ format_backtrace(json['backtrace'])
496
+ end
497
+
498
+ def format_warn_log(json, message, timestamp, prefix_str)
499
+ puts "#{prefix_str}#{c(:gray)}#{timestamp}#{c(:reset)} #{c(:yellow)}WARN#{c(:reset)} #{message}"
500
+ if json['error_class']
501
+ puts " #{c(:dim)}Error:#{c(:reset)} " \
502
+ "#{c(:red)}#{json['error_class']}#{c(:reset)}: #{json['error_message']}"
503
+ end
504
+ puts " #{c(:dim)}Path:#{c(:reset)} #{json['path']}" if json['path']
505
+ format_backtrace(json['backtrace'])
506
+ end
507
+
508
+ def format_info_log(json, message, timestamp, prefix_str)
509
+ case message
510
+ when 'Lambda invoked'
511
+ path = strip_namespace(json['path'])
512
+ puts "#{prefix_str}#{c(:gray)}#{timestamp}#{c(:reset)} " \
513
+ "#{c(:cyan)}Started#{c(:reset)} #{c(:bold)}#{json['http_method']}#{c(:reset)} #{path}"
514
+ when 'Request completed'
515
+ path = strip_namespace(json['path'])
516
+ sc = json['status_code'].to_i
517
+ sc_color = status_color(sc)
518
+ puts "#{prefix_str}#{c(:gray)}#{timestamp}#{c(:reset)} " \
519
+ "#{c(:cyan)}Completed#{c(:reset)} #{c(sc_color)}#{sc}#{c(:reset)} #{path}"
520
+ else
521
+ level = json['level']
522
+ level_col = level_color_for(level)
523
+ puts "#{prefix_str}#{c(:gray)}#{timestamp}#{c(:reset)} #{level_col}#{level}#{c(:reset)} #{message}" if level
524
+ end
525
+ end
526
+
527
+ def format_aws_error(json, prefix: nil)
528
+ prefix_str = prefix ? "#{c(:blue)}[#{prefix}]#{c(:reset)} " : ''
529
+ puts "#{prefix_str} #{c(:dim)}Error Type:#{c(:reset)} #{c(:red)}#{json['errorType']}#{c(:reset)}"
530
+ puts "#{prefix_str} #{c(:dim)}Error Message:#{c(:reset)} #{json['errorMessage']}"
531
+ return unless json['stackTrace'].is_a?(Array)
532
+
533
+ puts "#{prefix_str} #{c(:dim)}Stack Trace:#{c(:reset)}"
534
+ json['stackTrace'].first(20).each do |line|
535
+ if line.match?(%r{(controllers|models|lib|helpers)/})
536
+ puts "#{prefix_str} #{c(:yellow)}→#{c(:reset)} #{line}"
537
+ elsif line.include?('/var/task/')
538
+ puts "#{prefix_str} #{c(:cyan)}→#{c(:reset)} #{line}"
539
+ else
540
+ puts "#{prefix_str} #{c(:gray)} #{line}#{c(:reset)}"
541
+ end
542
+ end
543
+ end
544
+
545
+ def format_backtrace(backtrace)
546
+ return unless backtrace.is_a?(Array) && backtrace.any?
547
+
548
+ puts " #{c(:dim)}Backtrace:#{c(:reset)}"
549
+ backtrace.first(15).each do |line|
550
+ if line.match?(%r{(controllers|models|lib|helpers)/})
551
+ puts " #{c(:yellow)}→#{c(:reset)} #{line}"
552
+ else
553
+ puts " #{c(:gray)} #{line}#{c(:reset)}"
554
+ end
555
+ end
556
+ end
557
+
558
+ def should_show_level?(level)
559
+ return true unless level
560
+
561
+ levels = { 'DEBUG' => 1, 'INFO' => 2, 'WARN' => 3, 'ERROR' => 4 }
562
+ (levels[level] || 0) >= (levels[@level] || 2)
563
+ end
564
+
565
+ def level_color_for(level)
566
+ case level
567
+ when 'INFO' then c(:green)
568
+ when 'WARN' then c(:yellow)
569
+ when 'ERROR' then c(:red)
570
+ else c(:gray)
571
+ end
572
+ end
573
+
574
+ def status_color(code)
575
+ if code >= 500
576
+ :red
577
+ elsif code >= 400
578
+ :yellow
579
+ else
580
+ :green
581
+ end
582
+ end
583
+
584
+ def strip_namespace(path)
585
+ return path unless path
586
+
587
+ path.sub(%r{\A/[^/]+}, '')
588
+ end
589
+
590
+ def print_header(lambda_name)
591
+ full_name = "#{@app_name}-#{@env}-#{lambda_name}"
592
+ puts "#{c(:cyan)}═══════════════════════════════════════════════════════════════#{c(:reset)}"
593
+ puts "#{c(:bold)}Lambda Logs: #{c(:magenta)}#{full_name}#{c(:reset)}"
594
+ puts "#{c(:cyan)}═══════════════════════════════════════════════════════════════#{c(:reset)}"
595
+ end
596
+
597
+ def format_timestamp(epoch_ms)
598
+ return '' unless epoch_ms
599
+
600
+ Time.at(epoch_ms / 1000.0).strftime('%H:%M:%S')
601
+ end
602
+
603
+ def extract_time_from_line(line)
604
+ match = line.match(/\A(\d{4}-\d{2}-\d{2}T[\d:]+)/)
605
+ match ? match[1].split('T').last : ''
606
+ end
607
+
608
+ def parse_since(value)
609
+ num = value.to_i
610
+ case value
611
+ when /h\z/ then num * 60 * 60 * 1000
612
+ when /m\z/ then num * 60 * 1000
613
+ when /s\z/ then num * 1000
614
+ else num * 60 * 1000
615
+ end
616
+ end
617
+
618
+ def parse_json(str)
619
+ JSON.parse(str)
620
+ rescue StandardError
621
+ nil
622
+ end
623
+
624
+ def find_infra_dir
625
+ candidates = %w[infrastructure infra]
626
+ candidates.map { |d| File.join(Dir.pwd, d) }.find { |d| Dir.exist?(d) }
627
+ end
628
+
629
+ def c(name)
630
+ @color ? COLORS[name].to_s : ''
631
+ end
632
+ end
633
+ end
634
+ end
@@ -167,8 +167,8 @@ module Belt
167
167
  'lambda/controllers/application_controller.rb.erb' =>
168
168
  "#{@app_name}/lambda/controllers/api/application_controller.rb",
169
169
  'lambda/lib/routes/routes.rb.erb' => "#{@app_name}/lambda/lib/routes/api_routes.rb",
170
- 'config/routes.tf.rb.erb' => "#{@app_name}/config/routes.tf.rb",
171
- 'config/schema.tf.rb.erb' => "#{@app_name}/config/schema.tf.rb",
170
+ 'config/routes.rb.erb' => "#{@app_name}/config/routes.rb",
171
+ 'config/contracts.rb.erb' => "#{@app_name}/config/contracts.rb",
172
172
  'config/lambda/api.yml.erb' => "#{@app_name}/config/lambda/api.yml",
173
173
  'README.md.erb' => "#{@app_name}/README.md",
174
174
  'AGENTS.md.erb' => "#{@app_name}/AGENTS.md",
@@ -8,14 +8,14 @@ module Belt
8
8
  private
9
9
 
10
10
  def load_schema_models(routes_file)
11
- schema_file = resolve_schema_file(routes_file)
11
+ schema_file = resolve_contracts_file(routes_file)
12
12
  return [] unless schema_file && File.exist?(schema_file)
13
13
 
14
14
  Belt.instance_variable_set(:@application, nil)
15
15
  begin
16
16
  eval(File.read(schema_file), binding, schema_file) # rubocop:disable Security/Eval
17
17
  rescue StandardError => e
18
- warn "Warning: Failed to load schema file #{schema_file}: #{e.message}"
18
+ warn "Warning: Failed to load contracts file #{schema_file}: #{e.message}"
19
19
  return []
20
20
  end
21
21
 
@@ -23,15 +23,25 @@ module Belt
23
23
  build_models_from_schema(schema)
24
24
  end
25
25
 
26
- def resolve_schema_file(routes_file)
26
+ def resolve_contracts_file(routes_file)
27
27
  schema_file = @options[:schema_file]
28
28
  unless schema_file
29
29
  routes_dir = File.dirname(File.expand_path(routes_file))
30
- schema_file = File.join(routes_dir, 'schema.tf.rb')
30
+ # Check new convention first, then legacy names
31
+ candidates = [
32
+ File.join(routes_dir, 'contracts.rb'),
33
+ File.join(routes_dir, 'contracts.tf.rb'),
34
+ File.join(routes_dir, 'schema.tf.rb')
35
+ ]
36
+ schema_file = candidates.find { |f| File.exist?(f) }
37
+
31
38
  # Fall back to infrastructure/ if not found in same directory as routes
32
- unless File.exist?(schema_file)
33
- alt = 'infrastructure/schema.tf.rb'
34
- schema_file = alt if File.exist?(alt)
39
+ unless schema_file
40
+ legacy_candidates = [
41
+ 'infrastructure/contracts.rb',
42
+ 'infrastructure/schema.tf.rb'
43
+ ]
44
+ schema_file = legacy_candidates.find { |f| File.exist?(f) }
35
45
  end
36
46
  end
37
47
  schema_file