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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +87 -0
- data/README.md +48 -8
- data/lib/belt/action_router.rb +1 -1
- data/lib/belt/assets/welcome.css +119 -47
- data/lib/belt/cli/backup_runner.rb +37 -54
- data/lib/belt/cli/bucket_security.rb +10 -4
- data/lib/belt/cli/deploy_command.rb +48 -0
- data/lib/belt/cli/doctor_command.rb +208 -0
- data/lib/belt/cli/environment_command.rb +6 -6
- data/lib/belt/cli/frontend_command.rb +25 -12
- data/lib/belt/cli/frontend_setup_command.rb +41 -0
- data/lib/belt/cli/new_command.rb +106 -38
- data/lib/belt/cli/path_gem_materializer.rb +141 -0
- data/lib/belt/cli/setup_command.rb +35 -10
- data/lib/belt/cli.rb +4 -0
- data/lib/belt/configuration.rb +30 -0
- data/lib/belt/controllers/welcome_controller.rb +69 -2
- data/lib/belt/helpers/response.rb +35 -4
- data/lib/belt/http_status.rb +89 -0
- data/lib/belt/rendering.rb +1 -0
- data/lib/belt/version.rb +1 -1
- data/lib/belt/views/welcome/show.html.erb +31 -31
- data/lib/belt.rb +16 -0
- data/lib/belt_controller/base.rb +24 -1
- data/lib/belt_controller/implicit_response.rb +104 -0
- data/lib/templates/environment/backend.tf.erb +1 -0
- data/lib/templates/frontend/react/src/index.css +4 -4
- data/lib/templates/frontend/react/src/lib/apiClient.js.erb +20 -3
- data/lib/templates/frontend/react/src/pages/Home.jsx.erb +283 -4
- data/lib/templates/module/main.tf.erb +13 -3
- data/lib/templates/new_app/config/routes.tf.rb.erb +2 -1
- metadata +20 -1
data/lib/belt/cli/new_command.rb
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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(
|
|
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)
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
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 ' ✓
|
|
305
|
+
puts ' ✓ bundle install'
|
|
252
306
|
else
|
|
253
|
-
puts ' ⚠
|
|
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(
|
|
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 ' #
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
115
|
+
say "Creating state bucket: #{@bucket_name} (#{@region})"
|
|
109
116
|
create_bucket(@bucket_name)
|
|
110
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|