belt 0.1.5 → 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: 330331693d70f3a18ad04c47b4f0b07c69b6a75538291be891946e12c7863dfc
4
- data.tar.gz: 643ed0cad19c1124581d86b7f816ce199d372be0415c03b53c8f9d5f990bc3a9
3
+ metadata.gz: b09432f7426352033b67c6cc696bc90cddcae4b1facae08ce7b6fc08763b7eef
4
+ data.tar.gz: bb4617d6514bef6636b5b2efcfd32eaee6ebbc66b09a7e7f83581340c0fe5714
5
5
  SHA512:
6
- metadata.gz: ca9f5d87f6b8ab597106789e5a381aa3ba6f33b85cc2620552b8c9380ad5508d7f27d5ed34cea52b59858db91ab02653ee4a487044562f7c3c68739c3fa40779
7
- data.tar.gz: 36cbc894c45482981d1c3ddd34f0b59dd7e5259dd5de975175af756c54488693e6b0396c44b81b396442149a70ff6067d8d40046cef749d7119d248f49d57990
6
+ metadata.gz: 2c67896f36d6b09e394e19a73a92a6c8e31e9ec43071bf92c905f7d78ba260766d5b46aa2ee9d6c4d665c3313679557df527b866089bad240693fcdd1ce89e91
7
+ data.tar.gz: 8cf8956ea9a6227237be1dccc0cfe15b96ac9e75c3ddae543873cb067fcc5b8648fa23a1a258ccd3470afb9097ba54a7bc2dedfadcc701294b6ff8d9449ac755
Binary file
@@ -23,7 +23,7 @@ body {
23
23
  left: 50%;
24
24
  transform: translate(-50%, -50%);
25
25
  padding: 2rem 3rem;
26
- background: rgba(255, 255, 255, 0.88);
26
+ background: rgba(255, 255, 255, 0.50);
27
27
  backdrop-filter: blur(6px);
28
28
  -webkit-backdrop-filter: blur(6px);
29
29
  border-radius: 12px;
@@ -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)
@@ -1,5 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'fileutils'
4
+ require 'json'
5
+ require 'open3'
6
+ require 'tmpdir'
3
7
  require_relative 'env_resolver'
4
8
  require_relative 'terraform_command'
5
9
 
@@ -15,12 +19,15 @@ module Belt
15
19
  end
16
20
 
17
21
  auto_approve = false
22
+ rebuild = false
18
23
  filtered_args = []
19
24
 
20
25
  args.each do |arg|
21
26
  case arg
22
27
  when '--auto', '--yes', '-y'
23
28
  auto_approve = true
29
+ when '--rebuild'
30
+ rebuild = true
24
31
  when '-h', '--help'
25
32
  puts help_text
26
33
  exit 0
@@ -41,18 +48,24 @@ module Belt
41
48
  puts "\nDefaults to BELT_ENV or the first available environment."
42
49
  puts "\nOptions:"
43
50
  puts ' --auto, --yes, -y Skip confirmation prompt (auto-approve)'
51
+ puts ' --rebuild Rebuild and push Lambda code directly (bypasses Terraform)'
44
52
  puts ' -h, --help Show this help'
45
53
  puts "\nExamples:"
46
54
  puts ' belt deploy # Deploy dev (or BELT_ENV)'
47
55
  puts ' belt deploy prod # Deploy to prod'
48
56
  puts ' belt deploy dev --auto # Deploy without confirmation'
57
+ puts ' belt deploy --rebuild # Fast code push (no infra changes)'
49
58
  puts ' belt deploy frontend dev # Deploy frontend only'
50
59
  puts "\nNo environments found. Run `belt generate environment dev` first."
51
60
  exit 1
52
61
  end
53
62
  end
54
63
 
55
- new(env, auto_approve: auto_approve, extra_args: filtered_args).run
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
56
69
  end
57
70
 
58
71
  def self.help_text
@@ -63,22 +76,28 @@ module Belt
63
76
  belt deploy frontend <environment>
64
77
 
65
78
  This runs the full deployment lifecycle:
66
- 1. terraform init (initialize providers/modules)
67
- 2. terraform plan (preview changes)
68
- 3. Prompt for confirmation (unless --auto)
69
- 4. terraform apply (deploy changes)
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)
70
84
 
71
85
  Options:
72
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`.
73
90
  -h, --help Show this help
74
91
 
75
92
  Environment:
76
- Defaults to BELT_ENV if set, otherwise requires an argument.
93
+ Defaults to BELT_ENV if set, otherwise the first available environment.
77
94
 
78
95
  Examples:
79
96
  belt deploy # Deploy dev (or BELT_ENV)
80
97
  belt deploy prod # Deploy to prod
81
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
82
101
  belt deploy frontend dev # Deploy frontend assets only
83
102
  HELP
84
103
  end
@@ -88,6 +107,7 @@ module Belt
88
107
  @auto_approve = auto_approve
89
108
  @extra_args = extra_args
90
109
  @infra_dir = TerraformCommand.find_infrastructure_dir
110
+ @project_root = find_project_root
91
111
  end
92
112
 
93
113
  def run
@@ -96,6 +116,8 @@ module Belt
96
116
 
97
117
  puts "belt → deploying #{@env} (in #{env_dir}/)\n\n"
98
118
 
119
+ ensure_lockfile_consistent!
120
+
99
121
  Dir.chdir(env_dir) do
100
122
  run_init
101
123
  run_plan
@@ -108,8 +130,43 @@ module Belt
108
130
  puts "\n Run `belt server` to view your app locally (auto-connects to the deployed API)."
109
131
  end
110
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
+
111
150
  private
112
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
+
113
170
  def validate!
114
171
  unless @infra_dir
115
172
  abort "Error: No infrastructure/ directory found.\n" \
@@ -124,6 +181,262 @@ module Belt
124
181
  "Create it with: belt generate environment #{@env}"
125
182
  end
126
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
+
127
440
  def run_init
128
441
  puts '━━━ terraform init ━━━'
129
442
  success = system('terraform', 'init')
@@ -210,7 +210,7 @@ module Belt
210
210
  @name = name.downcase.gsub(/[^a-z0-9_]/, '_')
211
211
  @fields = fields
212
212
  @skip_views = skip_views
213
- @app_name = detect_app_name
213
+ @app_name = detect_namespace
214
214
  @module_name = @app_name.split(/[-_]/).map(&:capitalize).join
215
215
  @resource_name = @name.end_with?('s') ? @name : "#{@name}s"
216
216
  @singular_name = @name.end_with?('s') ? @name.chomp('s') : @name
@@ -107,7 +107,7 @@ module Belt
107
107
 
108
108
  def directories
109
109
  %W[
110
- #{@app_name}/lambda/controllers/#{@app_name}
110
+ #{@app_name}/lambda/controllers/api
111
111
  #{@app_name}/lambda/models
112
112
  #{@app_name}/lambda/models/concerns
113
113
  #{@app_name}/lambda/lib/routes
@@ -121,13 +121,13 @@ module Belt
121
121
  {
122
122
  'Gemfile.erb' => "#{@app_name}/Gemfile",
123
123
  'Rakefile.erb' => "#{@app_name}/Rakefile",
124
- 'lambda/api.rb.erb' => "#{@app_name}/lambda/#{@app_name}.rb",
124
+ 'lambda/api.rb.erb' => "#{@app_name}/lambda/api.rb",
125
125
  'lambda/config/environment.rb.erb' => "#{@app_name}/lambda/config/environment.rb",
126
126
  'lambda/models/application_record.rb.erb' => "#{@app_name}/lambda/models/application_record.rb",
127
127
  'lambda/models/concerns/timestampable.rb.erb' => "#{@app_name}/lambda/models/concerns/timestampable.rb",
128
128
  'lambda/controllers/application_controller.rb.erb' =>
129
- "#{@app_name}/lambda/controllers/#{@app_name}/application_controller.rb",
130
- 'lambda/lib/routes/routes.rb.erb' => "#{@app_name}/lambda/lib/routes/#{@app_name}_routes.rb",
129
+ "#{@app_name}/lambda/controllers/api/application_controller.rb",
130
+ 'lambda/lib/routes/routes.rb.erb' => "#{@app_name}/lambda/lib/routes/api_routes.rb",
131
131
  'infrastructure/routes.tf.rb.erb' => "#{@app_name}/infrastructure/routes.tf.rb",
132
132
  'infrastructure/schema.tf.rb.erb' => "#{@app_name}/infrastructure/schema.tf.rb",
133
133
  'README.md.erb' => "#{@app_name}/README.md",
@@ -144,7 +144,7 @@ module Belt
144
144
  subtitle = ENV.fetch('WELCOME_SUBTITLE', 'Your app is ready. Deploy to see it live on AWS.')
145
145
 
146
146
  css = load_asset('welcome.css') || ''
147
- image_b64 = load_image_base64('belt-default.png') || ''
147
+ image_b64 = load_image_base64('belt-default.jpg') || ''
148
148
 
149
149
  if @api_url
150
150
  deployed_html(css: css, image_b64: image_b64)
@@ -175,7 +175,7 @@ module Belt
175
175
  </head>
176
176
  <body>
177
177
  <div class="hero">
178
- <img src="data:image/png;base64,#{image_b64}" alt="Belt" class="hero-bg" />
178
+ <img src="data:image/jpeg;base64,#{image_b64}" alt="Belt" class="hero-bg" />
179
179
  <div class="overlay">
180
180
  <h1>#{@app_name}</h1>
181
181
  <p class="subtitle">Deployed and running on AWS</p>
@@ -208,7 +208,7 @@ module Belt
208
208
  </head>
209
209
  <body>
210
210
  <div class="hero">
211
- <img src="data:image/png;base64,#{image_b64}" alt="Belt" class="hero-bg" />
211
+ <img src="data:image/jpeg;base64,#{image_b64}" alt="Belt" class="hero-bg" />
212
212
  <div class="overlay">
213
213
  <h1>#{title}</h1>
214
214
  <p class="subtitle">#{subtitle}</p>
@@ -12,69 +12,17 @@ module Belt
12
12
  ASSETS_DIR = File.expand_path('../assets', __dir__)
13
13
 
14
14
  def show
15
- title = ENV.fetch('WELCOME_TITLE', 'Welcome to Belt')
16
- subtitle = ENV.fetch('WELCOME_SUBTITLE', 'API Gateway → Lambda → DynamoDB — all connected.')
17
- app_name = ENV.fetch('APP_NAME', 'my-app')
15
+ @title = ENV.fetch('WELCOME_TITLE', 'Welcome to Belt')
16
+ @subtitle = ENV.fetch('WELCOME_SUBTITLE', 'API Gateway → Lambda → DynamoDB — all connected.')
17
+ @css = welcome_css
18
+ @background_image = background_image_base64
19
+ @dynamodb_connected = dynamodb_connected?
18
20
 
19
- html_response(render_welcome_page(title: title, subtitle: subtitle, app_name: app_name))
21
+ render
20
22
  end
21
23
 
22
24
  private
23
25
 
24
- def render_welcome_page(title:, subtitle:, app_name:)
25
- <<~HTML
26
- <!DOCTYPE html>
27
- <html lang="en">
28
- <head>
29
- <meta charset="UTF-8">
30
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
31
- <title>#{escape_html(title)}</title>
32
- <style>#{welcome_css}</style>
33
- </head>
34
- <body>
35
- <div class="hero">
36
- <img src="data:image/png;base64,#{background_image_base64}" alt="Belt" class="hero-bg" />
37
- <div class="overlay">
38
- <h1>#{escape_html(title)}</h1>
39
- <p class="subtitle">#{escape_html(subtitle)}</p>
40
- </div>
41
- </div>
42
- <div class="container">
43
- <div class="stack-check">
44
- <div class="check-item check-pass">
45
- <span class="icon">✓</span>
46
- <span>API Gateway</span>
47
- </div>
48
- <div class="arrow">→</div>
49
- <div class="check-item check-pass">
50
- <span class="icon">✓</span>
51
- <span>Lambda</span>
52
- </div>
53
- <div class="arrow">→</div>
54
- #{dynamodb_check_html}
55
- </div>
56
- <div class="next-steps">
57
- <h2>Next Steps</h2>
58
- <ol>
59
- <li>Generate a resource: <code>belt g resource post title:string body:text</code></li>
60
- <li>Deploy: <code>belt deploy</code></li>
61
- <li>This page will be replaced once you define your own root route.</li>
62
- </ol>
63
- </div>
64
- </div>
65
- </body>
66
- </html>
67
- HTML
68
- end
69
-
70
- def dynamodb_check_html
71
- if dynamodb_connected?
72
- '<div class="check-item check-pass"><span class="icon">✓</span><span>DynamoDB</span></div>'
73
- else
74
- '<div class="check-item check-warn"><span class="icon">⚠</span><span>DynamoDB</span></div>'
75
- end
76
- end
77
-
78
26
  def dynamodb_connected?
79
27
  return false unless defined?(Aws::DynamoDB::Client)
80
28
 
@@ -85,17 +33,13 @@ module Belt
85
33
  false
86
34
  end
87
35
 
88
- def escape_html(text)
89
- text.to_s.gsub('&', '&amp;').gsub('<', '&lt;').gsub('>', '&gt;').gsub('"', '&quot;')
90
- end
91
-
92
36
  def welcome_css
93
- @welcome_css ||= File.read(File.join(ASSETS_DIR, 'welcome.css'))
37
+ @welcome_css_content ||= File.read(File.join(ASSETS_DIR, 'welcome.css'))
94
38
  end
95
39
 
96
40
  def background_image_base64
97
- @background_image_base64 ||= Base64.strict_encode64(
98
- File.binread(File.join(ASSETS_DIR, 'belt-default.png'))
41
+ @background_image_content ||= Base64.strict_encode64(
42
+ File.binread(File.join(ASSETS_DIR, 'belt-default.jpg'))
99
43
  )
100
44
  end
101
45
  end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erb'
4
+ require 'cgi'
5
+
6
+ module Belt
7
+ # Provides Rails-like ERB rendering for BeltController.
8
+ #
9
+ # Convention: templates live at `views/<controller_name>/<action>.html.erb`
10
+ # relative to the controller's gem or app directory.
11
+ #
12
+ # Usage:
13
+ # class WelcomeController < BeltController::Base
14
+ # def show
15
+ # @title = "Hello"
16
+ # render # auto-resolves to views/welcome/show.html.erb
17
+ # end
18
+ # end
19
+ #
20
+ # Or explicit:
21
+ # render template: "welcome/show"
22
+ # render inline: "<h1><%= @title %></h1>"
23
+ #
24
+ module Rendering
25
+ def self.included(base)
26
+ base.extend(ClassMethods)
27
+ end
28
+
29
+ module ClassMethods
30
+ # Override in subclasses to set a custom view path.
31
+ # Defaults to `views/` relative to the file that defines the controller.
32
+ def view_paths
33
+ @view_paths ||= []
34
+ end
35
+
36
+ def prepend_view_path(path)
37
+ view_paths.unshift(path)
38
+ end
39
+
40
+ def append_view_path(path)
41
+ view_paths << path
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ # Render an ERB template and return an html_response.
48
+ #
49
+ # Options:
50
+ # render — auto-resolve from controller/action
51
+ # render template: "ctrl/action" — explicit template path (no extension)
52
+ # render inline: "<%= @x %>" — render a string as ERB
53
+ # render status: 201 — custom status code
54
+ # render layout: false — skip layout (default: no layout)
55
+ #
56
+ def render(options = {})
57
+ status = options.fetch(:status, 200)
58
+
59
+ html = if options.key?(:inline)
60
+ render_erb_string(options[:inline])
61
+ else
62
+ template_path = resolve_template_path(options[:template])
63
+ render_erb_file(template_path)
64
+ end
65
+
66
+ html_response(html, status)
67
+ end
68
+
69
+ def render_erb_string(source)
70
+ erb = ERB.new(source, trim_mode: '-')
71
+ erb.result(binding)
72
+ end
73
+
74
+ def render_erb_file(path)
75
+ raise Belt::TemplateNotFound, "Template not found: #{path}" unless File.exist?(path)
76
+
77
+ source = File.read(path)
78
+ erb = ERB.new(source, trim_mode: '-')
79
+ erb.filename = path
80
+ erb.result(binding)
81
+ end
82
+
83
+ # HTML-escape helper, available in templates as `h(value)`
84
+ def h(text)
85
+ CGI.escapeHTML(text.to_s)
86
+ end
87
+
88
+ def resolve_template_path(explicit_template = nil)
89
+ template_name = explicit_template || default_template_name
90
+ search_paths = build_view_search_paths
91
+
92
+ search_paths.each do |base|
93
+ full_path = File.join(base, "#{template_name}.html.erb")
94
+ return full_path if File.exist?(full_path)
95
+ end
96
+
97
+ # If nothing found, return the first candidate so the error message is useful
98
+ File.join(search_paths.first || '.', "#{template_name}.html.erb")
99
+ end
100
+
101
+ def default_template_name
102
+ # PostsController → posts, Belt::WelcomeController → welcome
103
+ controller = self.class.name.split('::').last
104
+ .sub(/Controller$/, '')
105
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
106
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
107
+ .downcase
108
+ "#{controller}/#{action_name}"
109
+ end
110
+
111
+ def build_view_search_paths
112
+ paths = []
113
+
114
+ # 1. Class-level view paths (set by the controller or gem)
115
+ paths.concat(self.class.view_paths) if self.class.respond_to?(:view_paths)
116
+
117
+ # 2. Walk up the class hierarchy for inherited view paths
118
+ klass = self.class.superclass
119
+ while klass.respond_to?(:view_paths)
120
+ paths.concat(klass.view_paths)
121
+ klass = klass.superclass
122
+ end
123
+
124
+ # 3. App-level views (convention: lambda/views/ from Belt.root)
125
+ if defined?(Belt.root) && Belt.root
126
+ paths << File.join(Belt.root, 'lambda', 'views')
127
+ paths << File.join(Belt.root, 'views')
128
+ end
129
+
130
+ # 4. Gem-level fallback (views/ relative to this file — belt gem's own views)
131
+ paths << File.expand_path('views', __dir__)
132
+
133
+ paths.uniq
134
+ end
135
+ end
136
+ end
data/lib/belt/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Belt
4
- VERSION = '0.1.5'
4
+ VERSION = '0.1.6'
5
5
  end
@@ -0,0 +1,51 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title><%= h(@title) %></title>
7
+ <style><%= @css %></style>
8
+ </head>
9
+ <body>
10
+ <div class="hero">
11
+ <img src="data:image/jpeg;base64,<%= @background_image %>" alt="Belt" class="hero-bg" />
12
+ <div class="overlay">
13
+ <h1><%= h(@title) %></h1>
14
+ <p class="subtitle"><%= h(@subtitle) %></p>
15
+ </div>
16
+ </div>
17
+ <div class="container">
18
+ <div class="stack-check">
19
+ <div class="check-item check-pass">
20
+ <span class="icon">✓</span>
21
+ <span>API Gateway</span>
22
+ </div>
23
+ <div class="arrow">→</div>
24
+ <div class="check-item check-pass">
25
+ <span class="icon">✓</span>
26
+ <span>Lambda</span>
27
+ </div>
28
+ <div class="arrow">→</div>
29
+ <%- if @dynamodb_connected -%>
30
+ <div class="check-item check-pass">
31
+ <span class="icon">✓</span>
32
+ <span>DynamoDB</span>
33
+ </div>
34
+ <%- else -%>
35
+ <div class="check-item check-warn">
36
+ <span class="icon">⚠</span>
37
+ <span>DynamoDB</span>
38
+ </div>
39
+ <%- end -%>
40
+ </div>
41
+ <div class="next-steps">
42
+ <h2>Next Steps</h2>
43
+ <ol>
44
+ <li>Generate a resource: <code>belt g resource post title:string body:text</code></li>
45
+ <li>Deploy: <code>belt deploy</code></li>
46
+ <li>This page will be replaced once you define your own root route.</li>
47
+ </ol>
48
+ </div>
49
+ </div>
50
+ </body>
51
+ </html>
data/lib/belt.rb CHANGED
@@ -11,6 +11,7 @@ module Belt
11
11
  class AuthenticationError < StandardError; end
12
12
  class RecordNotFound < StandardError; end
13
13
  class ActionNotFound < StandardError; end
14
+ class TemplateNotFound < StandardError; end
14
15
 
15
16
  @controller_paths = []
16
17
 
@@ -6,10 +6,12 @@ require_relative '../belt/parameters'
6
6
  require_relative '../belt/helpers/response'
7
7
  require_relative '../belt/helpers/error_logging'
8
8
  require_relative '../belt/helpers/cors_origin'
9
+ require_relative '../belt/rendering'
9
10
 
10
11
  module BeltController
11
12
  class Base
12
13
  include Belt::Helpers::Response
14
+ include Belt::Rendering
13
15
 
14
16
  attr_reader :event, :body
15
17
 
@@ -20,7 +20,7 @@ provider "aws" {
20
20
 
21
21
  default_tags {
22
22
  tags = {
23
- Project = "<%= @app_name.capitalize %>"
23
+ Project = var.app_name
24
24
  Environment = var.environment
25
25
  ManagedBy = "Terraform"
26
26
  }
@@ -37,7 +37,20 @@ resource "conveyor_belt" "main" {
37
37
  source = "${path.module}/../routes.tf.rb"
38
38
  app_name = var.app_name
39
39
  lambda_source_dir = "${path.module}/../../lambda"
40
- lambda_shared_dirs = ["controllers", "helpers", "lib", "models", "templates"]
40
+ lambda_shared_dirs = ["controllers", "helpers", "lib", "models", "views"]
41
41
  frontend_urls = ["http://localhost:3000"]
42
- friendly_errors = true
42
+ friendly_errors = var.environment != "prod"
43
+
44
+ # Per-lambda configuration: env vars, timeouts, memory
45
+ lambda_config = {
46
+ api = {
47
+ # timeout = 30
48
+ # memory_size = 256
49
+
50
+ env_vars = {
51
+ WELCOME_TITLE = "Welcome to ${var.app_name}"
52
+ WELCOME_SUBTITLE = "API Gateway → Lambda → DynamoDB — all connected."
53
+ }
54
+ }
55
+ }
43
56
  }
@@ -1,5 +1,5 @@
1
1
  Belt.application.routes.draw do
2
- namespace :<%= @app_name %> do
2
+ namespace :api do
3
3
  get "/", action: :show, controller: :welcome
4
4
  # resources :posts
5
5
  end
@@ -10,12 +10,12 @@ ActiveItem.configure do |config|
10
10
  config.environment = ENV['ENVIRONMENT']
11
11
  end
12
12
 
13
- require_relative 'lib/routes/<%= @app_name %>_routes'
13
+ require_relative 'lib/routes/api_routes'
14
14
  <% @resources&.each do |r| -%>
15
15
  require_relative 'controllers/<%= @app_name %>/<%= r %>_controller'
16
16
  <% end -%>
17
17
 
18
- ROUTER = Belt::ActionRouter.new(routes: Routes::<%= @module_name.upcase %>, namespace: '<%= @app_name %>')
18
+ ROUTER = Belt::ActionRouter.new(routes: Routes::API, namespace: 'api')
19
19
 
20
20
  def execute(path:, body:, event:)
21
21
  ROUTER.route(event: event, body: body)
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module <%= @module_name %>Controllers
3
+ module ApiControllers
4
4
  class ApplicationController < BeltController::Base
5
5
  end
6
6
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Routes
4
- <%= @module_name.upcase %> = [
4
+ API = [
5
5
  { verb: 'GET', path: '/', controller: 'welcome', action: 'show' },
6
6
  # { verb: 'GET', path: '/posts', controller: 'posts', action: 'index' },
7
7
  # { verb: 'POST', path: '/posts', controller: 'posts', action: 'create' },
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: belt
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.5
4
+ version: 0.1.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stowzilla
@@ -52,7 +52,7 @@ files:
52
52
  - exe/belt
53
53
  - lib/belt.rb
54
54
  - lib/belt/action_router.rb
55
- - lib/belt/assets/belt-default.png
55
+ - lib/belt/assets/belt-default.jpg
56
56
  - lib/belt/assets/welcome.css
57
57
  - lib/belt/cli.rb
58
58
  - lib/belt/cli/app_detection.rb
@@ -82,10 +82,12 @@ files:
82
82
  - lib/belt/lambda_handler.rb
83
83
  - lib/belt/observability.rb
84
84
  - lib/belt/parameters.rb
85
+ - lib/belt/rendering.rb
85
86
  - lib/belt/root.rb
86
87
  - lib/belt/route_dsl.rb
87
88
  - lib/belt/table_inference.rb
88
89
  - lib/belt/version.rb
90
+ - lib/belt/views/welcome/show.html.erb
89
91
  - lib/belt_controller/base.rb
90
92
  - lib/templates/environment/backend.tf.erb
91
93
  - lib/templates/environment/main.tf.erb
Binary file