belt 0.2.8 → 0.2.10

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.
@@ -18,6 +18,7 @@ module Belt
18
18
  bucket = nil
19
19
  environments = nil
20
20
  domain = nil
21
+ verbose = false
21
22
 
22
23
  i = 0
23
24
  while i < args.length
@@ -49,6 +50,8 @@ module Belt
49
50
  domain = args[i]
50
51
  when /^--domain=/
51
52
  domain = arg.split('=', 2).last
53
+ when '-v', '--verbose'
54
+ verbose = true
52
55
  else
53
56
  app_name ||= arg unless arg.start_with?('-')
54
57
  end
@@ -65,22 +68,33 @@ module Belt
65
68
  puts ' --state-bucket BUCKET_NAME Alias for --bucket'
66
69
  puts ' --environments dev,prod Comma-separated environments (default: dev,prod)'
67
70
  puts ' Use "none" to skip environment setup'
71
+ puts ' -v, --verbose List every created file (Rails-style)'
68
72
  exit 1
69
73
  end
70
74
 
71
- new(app_name, frontend: frontend, bucket: bucket, environments: environments, domain: domain).generate
75
+ new(
76
+ app_name,
77
+ frontend: frontend,
78
+ bucket: bucket,
79
+ environments: environments,
80
+ domain: domain,
81
+ verbose: verbose
82
+ ).generate
72
83
  end
73
84
 
74
- def initialize(app_name, frontend: nil, bucket: nil, environments: nil, domain: nil)
85
+ # rubocop:disable Metrics/ParameterLists -- keyword options for generator flags
86
+ def initialize(app_name, frontend: nil, bucket: nil, environments: nil, domain: nil, verbose: false)
75
87
  @app_name = app_name.gsub(/[^a-z0-9_-]/i, '_').downcase
76
88
  @module_name = @app_name.split(/[-_]/).map(&:capitalize).join
77
89
  @frontend = frontend
78
90
  @bucket = bucket
79
91
  @domain = domain
80
92
  @environments = parse_environments(environments)
93
+ @verbose = verbose
81
94
  @resolved_bucket = nil
82
95
  @state_setup_succeeded = false
83
96
  end
97
+ # rubocop:enable Metrics/ParameterLists
84
98
 
85
99
  def generate
86
100
  if Dir.exist?(@app_name)
@@ -88,9 +102,12 @@ module Belt
88
102
  exit 1
89
103
  end
90
104
 
105
+ preflight_check
106
+
91
107
  puts "Creating new Belt application: #{@app_name}"
92
108
  create_structure
93
109
  generate_module
110
+ puts ' create app skeleton' unless @verbose
94
111
  generate_environments
95
112
  generate_frontend if @frontend
96
113
  init_git
@@ -110,6 +127,19 @@ module Belt
110
127
  env_string.split(',').map(&:strip).reject(&:empty?)
111
128
  end
112
129
 
130
+ def preflight_check
131
+ missing = []
132
+ { 'aws' => 'AWS CLI', 'terraform' => 'Terraform' }.each do |cmd, label|
133
+ _, status = Open3.capture2e(cmd, '--version')
134
+ missing << label unless status.success?
135
+ end
136
+
137
+ return if missing.empty?
138
+
139
+ puts "⚠ Missing: #{missing.join(', ')}"
140
+ puts " Belt requires these tools to deploy. Install them, then run `belt doctor` to verify.\n\n"
141
+ end
142
+
113
143
  def create_structure
114
144
  directories.each { |dir| create_dir(dir) }
115
145
  files.each { |src, dest| create_file(src, dest) }
@@ -162,7 +192,7 @@ module Belt
162
192
  template_path = File.join(module_template_dir, template_name)
163
193
  content = ERB.new(File.read(template_path), trim_mode: '-').result(binding)
164
194
  File.write(dest_path, content)
165
- puts " create #{dest_path}"
195
+ puts " create #{dest_path}" if @verbose
166
196
  end
167
197
  end
168
198
 
@@ -171,43 +201,62 @@ module Belt
171
201
 
172
202
  Dir.chdir(@app_name) do
173
203
  @environments.each do |env_name|
174
- Belt::CLI::EnvironmentCommand.new(env_name, quiet: true, domain: @domain).generate
204
+ Belt::CLI::EnvironmentCommand.new(
205
+ env_name,
206
+ quiet: !@verbose,
207
+ announce: false,
208
+ domain: @domain
209
+ ).generate
175
210
  end
176
211
  end
212
+ puts " create environments (#{@environments.join(', ')})" unless @verbose
177
213
  end
178
214
 
179
215
  def setup_state
180
216
  return if @environments.empty?
181
217
 
182
- Dir.chdir(@app_name) do
183
- # Shared bucket: one per AWS account, all belt apps share it
184
- @resolved_bucket = @bucket || 'belt-terraform-state'
185
-
186
- # Attempt to actually create the bucket if credentials are available
187
- if aws_configured?
188
- puts "\n Setting up Terraform state bucket..."
189
- begin
190
- Belt::CLI::SetupCommand.new(['--bucket', @resolved_bucket]).run_state_setup
191
- @state_setup_succeeded = true
192
- rescue SystemExit
193
- puts ' ⚠ State bucket setup encountered an issue — run `belt setup state` to retry.'
194
- @state_setup_succeeded = false
195
- end
196
- else
197
- puts "\n State bucket: #{@resolved_bucket}"
198
- puts " State keys: #{s3_safe_name(@app_name)}/<env>/terraform.tfstate"
199
- if @aws_error&.include?('ForbiddenException') || @aws_error&.include?('AccessDenied')
200
- puts ' ⚠ AWS credentials found but access denied — check your profile/role configuration.'
201
- puts " #{@aws_error}" if @aws_error
202
- else
203
- puts ' ⚠ AWS credentials not detected — skipping state bucket creation.'
204
- end
205
- puts ' Run `belt setup state` after configuring credentials (aws sso login / AWS_PROFILE).'
206
- @state_setup_succeeded = false
207
- end
218
+ Dir.chdir(@app_name) { create_or_skip_state_bucket }
219
+ end
220
+
221
+ def create_or_skip_state_bucket
222
+ # Shared bucket: one per AWS account, all belt apps share it.
223
+ # Account ID suffix makes the name globally unique (S3 is a global namespace).
224
+ @resolved_bucket = if @bucket
225
+ @bucket
226
+ elsif aws_configured?
227
+ "belt-terraform-state-#{@aws_account_id}"
228
+ else
229
+ 'belt-terraform-state'
230
+ end
231
+
232
+ if aws_configured?
233
+ create_state_bucket!
234
+ else
235
+ warn_missing_aws_for_state
236
+ @state_setup_succeeded = false
208
237
  end
209
238
  end
210
239
 
240
+ def create_state_bucket!
241
+ setup = Belt::CLI::SetupCommand.new(['--bucket', @resolved_bucket], quiet: true)
242
+ setup.run_state_setup
243
+ @resolved_bucket = setup.bucket_name
244
+ @state_setup_succeeded = true
245
+ puts " ✓ state bucket #{@resolved_bucket}"
246
+ rescue SystemExit
247
+ puts ' ⚠ state bucket setup failed — run `belt setup state` to retry'
248
+ @state_setup_succeeded = false
249
+ end
250
+
251
+ def warn_missing_aws_for_state
252
+ if @aws_error&.include?('ForbiddenException') || @aws_error&.include?('AccessDenied')
253
+ puts ' ⚠ AWS credentials found but access denied — check profile/role'
254
+ else
255
+ puts ' ⚠ AWS credentials not detected — skipped state bucket'
256
+ end
257
+ puts ' run `belt doctor`, then `belt setup state`'
258
+ end
259
+
211
260
  def aws_configured?
212
261
  output, status = Open3.capture2e('aws', 'sts', 'get-caller-identity')
213
262
  if status.success?
@@ -226,45 +275,64 @@ module Belt
226
275
 
227
276
  def create_dir(dir)
228
277
  FileUtils.mkdir_p(dir)
229
- puts " create #{dir}/"
278
+ puts " create #{dir}/" if @verbose
230
279
  end
231
280
 
232
281
  def create_file(template_name, dest_path)
233
282
  template_path = File.join(TEMPLATE_DIR, template_name)
234
283
  content = ERB.new(File.read(template_path), trim_mode: '-').result(binding)
235
284
  File.write(dest_path, content)
236
- puts " create #{dest_path}"
285
+ puts " create #{dest_path}" if @verbose
237
286
  end
238
287
 
239
288
  def init_git
240
289
  Dir.chdir(@app_name) do
241
290
  system('git', 'init', '--quiet')
242
291
  end
243
- puts " init #{@app_name}/.git/"
292
+ if @verbose
293
+ puts " init #{@app_name}/.git/"
294
+ else
295
+ puts ' init git'
296
+ end
244
297
  end
245
298
 
246
299
  def run_bundle_install
247
300
  Dir.chdir(@app_name) do
248
- puts "\n Running bundle install..."
301
+ puts "\n Running bundle install..." if @verbose
302
+ # Always --quiet: bundle's own progress is noise either way
249
303
  success = system('bundle', 'install', '--quiet')
250
304
  if success
251
- puts ' ✓ Bundle installed'
305
+ puts ' ✓ bundle install'
252
306
  else
253
- puts ' ⚠ bundle install failed — run it manually after resolving issues.'
307
+ puts ' ⚠ bundle install failed — run it manually after resolving issues'
254
308
  end
255
309
  end
256
310
  end
257
311
 
258
312
  def generate_frontend
313
+ frontend_cmd = nil
259
314
  Dir.chdir(@app_name) do
260
- Belt::CLI::FrontendCommand.new(@frontend).generate
315
+ frontend_cmd = Belt::CLI::FrontendCommand.new(
316
+ @frontend,
317
+ quiet: !@verbose,
318
+ announce: false
319
+ )
320
+ frontend_cmd.generate
321
+ end
322
+ return if @verbose
323
+
324
+ puts " create frontend (#{@frontend})"
325
+ if frontend_cmd.npm_ok?
326
+ puts ' ✓ npm dependencies'
327
+ else
328
+ puts ' ⚠ npm install failed — run `cd frontend && npm install`'
261
329
  end
262
330
  end
263
331
 
264
332
  def print_next_steps
265
333
  puts "\nNext steps:"
266
334
  unless @state_setup_succeeded
267
- puts ' # Configure AWS credentials (aws sso login / AWS_PROFILE)'
335
+ puts ' belt doctor # Verify AWS CLI, Terraform & credentials'
268
336
  puts ' belt setup state # Create the S3 state bucket'
269
337
  end
270
338
  puts ' belt deploy # Deploy to AWS'
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'open3'
5
+ require 'rubygems/package'
6
+
7
+ module Belt
8
+ module CLI
9
+ # Turns Gemfile `path:` gems into real `.gem` files in vendor/cache and
10
+ # rewrites the Gemfile/lock so Docker `bundle install` produces a normal
11
+ # gem install (with specifications/). Required for Lambda bare `require`
12
+ # without bundler/setup.
13
+ #
14
+ # Only mutates files under +build_dir+ — never the app's real Gemfile.
15
+ class PathGemMaterializer
16
+ PathSource = Struct.new(:remote, :gems, keyword_init: true)
17
+ PathGem = Struct.new(:name, :version, keyword_init: true)
18
+
19
+ def self.materialize!(build_dir, project_root:)
20
+ new(build_dir, project_root: project_root).materialize!
21
+ end
22
+
23
+ def initialize(build_dir, project_root:)
24
+ @build_dir = build_dir
25
+ @project_root = project_root
26
+ @gemfile = File.join(build_dir, 'Gemfile')
27
+ @lockfile = File.join(build_dir, 'Gemfile.lock')
28
+ end
29
+
30
+ # @return [Array<String>] names of gems materialized (empty if none)
31
+ def materialize!
32
+ return [] unless File.exist?(@gemfile) && File.exist?(@lockfile)
33
+
34
+ sources = parse_path_sources(File.read(@lockfile))
35
+ return [] if sources.empty?
36
+
37
+ cache_dir = File.join(@build_dir, 'vendor', 'cache')
38
+ FileUtils.mkdir_p(cache_dir)
39
+
40
+ materialized = []
41
+ sources.each do |source|
42
+ source_path = resolve_remote(source.remote)
43
+ abort "✗ path gem source missing: #{source.remote} (resolved #{source_path})" unless Dir.exist?(source_path)
44
+
45
+ source.gems.each do |gem|
46
+ gem_file = build_gem(source_path, gem)
47
+ dest = File.join(cache_dir, File.basename(gem_file))
48
+ FileUtils.cp(gem_file, dest)
49
+ FileUtils.rm_f(gem_file)
50
+ rewrite_gemfile_path_to_version!(gem.name, gem.version)
51
+ materialized << gem.name
52
+ end
53
+ end
54
+
55
+ return [] if materialized.empty?
56
+
57
+ relock!(materialized)
58
+ materialized
59
+ end
60
+
61
+ private
62
+
63
+ def parse_path_sources(lockfile_content)
64
+ sources = []
65
+ lockfile_content.scan(/^PATH\n remote: (.+)\n specs:\n((?: .+\n)*)/) do |remote, specs_block|
66
+ gems = []
67
+ specs_block.each_line do |line|
68
+ next unless line.match?(/^ \S/)
69
+ next if line.start_with?(' ') # dependency lines are deeper
70
+
71
+ match = line.match(/^ (\S+)\s+\(([^)]+)\)/)
72
+ gems << PathGem.new(name: match[1], version: match[2]) if match
73
+ end
74
+ sources << PathSource.new(remote: remote.strip, gems: gems) if gems.any?
75
+ end
76
+ sources
77
+ end
78
+
79
+ def resolve_remote(remote)
80
+ return remote if remote.start_with?('/')
81
+
82
+ File.expand_path(remote, @project_root)
83
+ end
84
+
85
+ def build_gem(source_path, gem)
86
+ gemspec = find_gemspec(source_path, gem.name)
87
+ abort "✗ No gemspec for path gem #{gem.name} under #{source_path}" unless gemspec
88
+
89
+ Dir.chdir(source_path) do
90
+ spec = Gem::Specification.load(File.basename(gemspec))
91
+ abort "✗ Failed to load #{gemspec}" unless spec
92
+
93
+ if spec.version.to_s != gem.version
94
+ abort "✗ path gem #{gem.name} version mismatch: gemspec #{spec.version}, lock #{gem.version}"
95
+ end
96
+
97
+ # Gem::Package.build writes the .gem into cwd
98
+ built = Gem::Package.build(spec)
99
+ File.expand_path(built)
100
+ end
101
+ end
102
+
103
+ def find_gemspec(source_path, gem_name)
104
+ preferred = File.join(source_path, "#{gem_name}.gemspec")
105
+ return preferred if File.exist?(preferred)
106
+
107
+ Dir.glob(File.join(source_path, '*.gemspec')).first
108
+ end
109
+
110
+ def rewrite_gemfile_path_to_version!(name, version)
111
+ content = File.read(@gemfile)
112
+ new_content = content.lines.map { |line| convert_path_line(line, name, version) }.join
113
+ abort "✗ Could not rewrite path: for gem #{name.inspect} in build Gemfile" if new_content == content
114
+ File.write(@gemfile, new_content)
115
+ end
116
+
117
+ # Converts `gem 'name', path: '...'` (and variants with other kwargs) into
118
+ # a version-pinned line. Leaves non-matching lines alone.
119
+ def convert_path_line(line, name, version)
120
+ return line unless line.match?(/gem\s+(['"])#{Regexp.escape(name)}\1/)
121
+ return line unless line.match?(/\bpath:\s*/)
122
+
123
+ quote = line[/gem\s+(['"])/, 1] || "'"
124
+ cleaned = line.sub(/,?\s*path:\s*['"][^'"]+['"]/, '')
125
+ cleaned.sub(/gem\s+(['"])#{Regexp.escape(name)}\1/) do
126
+ "gem #{quote}#{name}#{quote}, #{quote}#{version}#{quote}"
127
+ end
128
+ end
129
+
130
+ def relock!(gem_names)
131
+ Dir.chdir(@build_dir) do
132
+ # vendor/cache has the built .gem — Bundler resolves the unpublished version
133
+ output, status = Open3.capture2e('bundle', 'lock', '--update', *gem_names)
134
+ return if status.success?
135
+
136
+ abort "✗ Failed to re-lock after materializing path gems (#{gem_names.join(', ')}):\n#{output}"
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end
@@ -38,11 +38,12 @@ module Belt
38
38
  end
39
39
  end
40
40
 
41
- def initialize(args = [])
41
+ def initialize(args = [], quiet: false)
42
42
  @app_name = detect_app_name
43
43
  @env_name = nil
44
44
  @custom_bucket = nil
45
45
  @select_mode = false
46
+ @quiet = quiet
46
47
 
47
48
  parse_args(args)
48
49
 
@@ -67,7 +68,7 @@ module Belt
67
68
  @bucket_name = interactive_bucket_selection if @select_mode
68
69
  setup_or_verify_bucket
69
70
  apply_lifecycle(@bucket_name)
70
- puts ' ensure lifecycle rules (90-day noncurrent expiration)'
71
+ say ' ensure lifecycle rules (90-day noncurrent expiration)'
71
72
  update_backend_config
72
73
  print_success_message
73
74
  end
@@ -81,12 +82,12 @@ module Belt
81
82
  end
82
83
 
83
84
  def verify_existing_bucket
84
- puts "Found existing bucket: #{@bucket_name}"
85
+ say "Found existing bucket: #{@bucket_name}"
85
86
  audit = audit_bucket_security(@bucket_name)
86
- print_security_audit(audit)
87
+ print_security_audit(audit) unless @quiet
87
88
 
88
89
  if audit.values.all?
89
- puts "\n✓ Bucket '#{@bucket_name}' passes all security checks"
90
+ say "\n✓ Bucket '#{@bucket_name}' passes all security checks"
90
91
  else
91
92
  prompt_and_harden(audit)
92
93
  end
@@ -94,6 +95,12 @@ module Belt
94
95
 
95
96
  def prompt_and_harden(audit)
96
97
  puts "\n⚠ Bucket '#{@bucket_name}' has security issues."
98
+ # Auto-harden during belt new (quiet); interactive otherwise
99
+ if @quiet
100
+ harden_bucket(@bucket_name, audit)
101
+ return
102
+ end
103
+
97
104
  print "\nApply security hardening? [Y/n] "
98
105
  response = $stdin.gets&.strip&.downcase
99
106
  if response.nil? || response.empty? || response == 'y'
@@ -105,13 +112,15 @@ module Belt
105
112
  end
106
113
 
107
114
  def create_new_bucket
108
- puts "Creating state bucket: #{@bucket_name} (#{@region})"
115
+ say "Creating state bucket: #{@bucket_name} (#{@region})"
109
116
  create_bucket(@bucket_name)
110
- puts " create s3://#{@bucket_name}"
117
+ say " create s3://#{@bucket_name}"
111
118
  harden_bucket(@bucket_name, {})
112
119
  end
113
120
 
114
121
  def print_success_message
122
+ return if @quiet
123
+
115
124
  puts "\n✓ State bucket '#{@bucket_name}' is ready!"
116
125
  if @env_name
117
126
  puts "\n cd infrastructure/#{@env_name} && terraform init"
@@ -120,6 +129,9 @@ module Belt
120
129
  end
121
130
  end
122
131
 
132
+ # Expose for quiet callers (e.g. belt new) that print their own summary
133
+ attr_reader :bucket_name
134
+
123
135
  private
124
136
 
125
137
  def parse_args(args)
@@ -138,8 +150,15 @@ module Belt
138
150
  end
139
151
  end
140
152
 
153
+ # One shared state bucket per AWS account (all belt apps use it).
154
+ # Account ID keeps the name globally unique — S3 names are a global namespace.
155
+ # Pattern: belt-terraform-state-<account_id>
141
156
  def resolve_bucket_name
142
- @custom_bucket || 'belt-terraform-state'
157
+ return @custom_bucket if @custom_bucket
158
+ return "belt-terraform-state-#{@aws_account_id}" if @aws_account_id
159
+
160
+ # Placeholder until credentials are available (belt setup state re-resolves)
161
+ 'belt-terraform-state'
143
162
  end
144
163
 
145
164
  # --- Interactive selection ---
@@ -238,9 +257,11 @@ module Belt
238
257
  end
239
258
 
240
259
  def run!(*args)
241
- return if system(*args)
260
+ output, status = Open3.capture2e(*args)
261
+ return if status.success?
242
262
 
243
263
  puts "\n✗ Command failed: #{args.shelljoin}"
264
+ puts output.strip unless output.strip.empty?
244
265
  exit 1
245
266
  end
246
267
 
@@ -249,6 +270,10 @@ module Belt
249
270
  status.success? ? output : nil
250
271
  end
251
272
 
273
+ def say(message)
274
+ puts message unless @quiet
275
+ end
276
+
252
277
  # --- Backend config ---
253
278
 
254
279
  def update_backend_config
@@ -268,7 +293,7 @@ module Belt
268
293
  updated = content.gsub(/bucket\s*=\s*"[^"]+"/, "bucket = \"#{@bucket_name}\"")
269
294
  if updated != content
270
295
  File.write(backend_file, updated)
271
- puts " update #{backend_file} → bucket = \"#{@bucket_name}\""
296
+ say " update #{backend_file} → bucket = \"#{@bucket_name}\""
272
297
  end
273
298
  end
274
299
  end
data/lib/belt/cli.rb CHANGED
@@ -21,6 +21,7 @@ require_relative 'cli/routes_command'
21
21
  require_relative 'cli/lambda_config_command'
22
22
  require_relative 'cli/tasks_command'
23
23
  require_relative 'cli/console_command'
24
+ require_relative 'cli/doctor_command'
24
25
 
25
26
  module Belt
26
27
  module CLI
@@ -33,6 +34,7 @@ module Belt
33
34
  %w[console c] => Belt::CLI::ConsoleCommand,
34
35
  %w[tasks --tasks -T] => Belt::CLI::TasksCommand,
35
36
  'setup' => Belt::CLI::SetupCommand,
37
+ 'doctor' => Belt::CLI::DoctorCommand,
36
38
  'deploy' => Belt::CLI::DeployCommand,
37
39
  'frontend' => Belt::CLI::FrontendEnvCommand,
38
40
  %w[server s] => Belt::CLI::ServerCommand,
@@ -114,6 +116,7 @@ module Belt
114
116
  setup state Create/select S3 state bucket
115
117
  setup tables <env> Generate DynamoDB tables from schema
116
118
  setup frontend <env> Generate S3 + CloudFront infrastructure
119
+ doctor Check system dependencies and AWS config
117
120
  init [environment] <env> terraform init for environment
118
121
  plan [environment] <env> terraform plan for environment
119
122
  apply [environment] <env> terraform apply for environment
@@ -133,6 +136,7 @@ module Belt
133
136
 
134
137
  Examples:
135
138
  belt new blog --frontend react
139
+ belt new blog --frontend react -v # list every created file
136
140
  belt generate scaffold post title:string content:text status:string
137
141
  belt destroy scaffold post
138
142
  belt generate frontend react
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Belt
4
+ # Runtime configuration for Belt apps (set in lambda/config/environment.rb).
5
+ #
6
+ # Belt.configure do |config|
7
+ # config.default_format = :json # or :html
8
+ # end
9
+ #
10
+ # Note: infrastructure/<env>/belt.rb uses a separate sandboxed DSL for CLI
11
+ # deploy/backup settings — it does not share this object.
12
+ class Configuration
13
+ VALID_FORMATS = %i[json html].freeze
14
+
15
+ def initialize
16
+ @default_format = :json
17
+ end
18
+
19
+ attr_reader :default_format
20
+
21
+ def default_format=(value)
22
+ format = value.to_sym
23
+ unless VALID_FORMATS.include?(format)
24
+ raise ArgumentError, "default_format must be :json or :html (got #{value.inspect})"
25
+ end
26
+
27
+ @default_format = format
28
+ end
29
+ end
30
+ end
@@ -6,23 +6,90 @@ module Belt
6
6
  # Gem-embedded welcome controller that serves the default "it works!" page.
7
7
  # Resolved via the ActionRouter's Belt:: namespace fallback when no app-defined
8
8
  # WelcomeController exists. Gets replaced once the user defines their own root route.
9
+ #
10
+ # Dual format:
11
+ # - Browser / no Accept preference → HTML hero + stack check
12
+ # - Accept: application/json (SPA / apiClient) → JSON payload for the React shell
13
+ #
14
+ # The hero image (lib/belt/assets/belt-default.jpg) is the single source of truth —
15
+ # HTML embeds it, and JSON returns the same bytes as a data URI so the SPA matches.
9
16
  class WelcomeController < BeltController::Base
10
17
  skip_before_action :authenticate!
11
18
 
12
19
  ASSETS_DIR = File.expand_path('../assets', __dir__)
20
+ # Scaffold gives model + controller + routes + schema (not model-only).
21
+ SCAFFOLD_HINT_CMD = 'belt generate scaffold Post title content:text'
13
22
 
14
23
  def show
15
24
  @title = ENV.fetch('WELCOME_TITLE', 'Welcome to Belt')
16
25
  @subtitle = ENV.fetch('WELCOME_SUBTITLE', 'API Gateway → Lambda → DynamoDB — all connected.')
17
- @css = welcome_css
18
- @background_image = background_image_base64
19
26
  @dynamodb_connected = dynamodb_connected?
27
+ @scaffold_hint_cmd = SCAFFOLD_HINT_CMD
28
+
29
+ return success_response(welcome_payload) if wants_json?
20
30
 
31
+ @css = welcome_css
32
+ @background_image = background_image_base64
21
33
  render
22
34
  end
23
35
 
24
36
  private
25
37
 
38
+ def welcome_payload
39
+ {
40
+ title: @title,
41
+ subtitle: @subtitle,
42
+ # Same gem asset as the HTML welcome page — edit belt-default.jpg once.
43
+ background_image: "data:image/jpeg;base64,#{background_image_base64}",
44
+ stack: {
45
+ api_gateway: true,
46
+ lambda: true,
47
+ dynamodb: @dynamodb_connected
48
+ },
49
+ # One tip only (was duplicated as dynamodb_hint + first next_step).
50
+ next_steps: [
51
+ "Scaffold a resource: #{SCAFFOLD_HINT_CMD}",
52
+ 'Deploy: belt deploy',
53
+ 'This page will be replaced once you define your own root route.'
54
+ ]
55
+ }
56
+ end
57
+
58
+ # SPA apiClient and fetch(..., { headers: { Accept: "application/json" } })
59
+ def wants_json?
60
+ return true if params['format'].to_s == 'json'
61
+
62
+ accept = header_value('Accept') || header_value('accept') || ''
63
+ return false if accept.empty?
64
+
65
+ # Prefer JSON when it's listed and not beaten by an explicit HTML preference
66
+ json_q = accept_quality(accept, 'application/json')
67
+ html_q = accept_quality(accept, 'text/html')
68
+ json_q.positive? && json_q >= html_q
69
+ end
70
+
71
+ def header_value(name)
72
+ headers = event.is_a?(Hash) ? event['headers'] : nil
73
+ return nil unless headers.is_a?(Hash)
74
+
75
+ headers[name] || headers[name.downcase] || headers[name.split('-').map(&:capitalize).join('-')]
76
+ end
77
+
78
+ def accept_quality(accept, media_type)
79
+ accept.split(',').each do |part|
80
+ type, *params = part.strip.split(';').map(&:strip)
81
+ # Exact type only — do not treat */* as JSON (curl/browsers send that by default)
82
+ next unless type == media_type
83
+
84
+ q = 1.0
85
+ params.each do |p|
86
+ q = p.split('=', 2).last.to_f if p.start_with?('q=')
87
+ end
88
+ return q
89
+ end
90
+ 0.0
91
+ end
92
+
26
93
  def dynamodb_connected?
27
94
  return false unless defined?(Aws::DynamoDB::Client)
28
95