belt 0.2.9 → 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/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
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'open3'
|
|
5
|
+
|
|
6
|
+
module Belt
|
|
7
|
+
module CLI
|
|
8
|
+
class DoctorCommand
|
|
9
|
+
REQUIRED_TOOLS = [
|
|
10
|
+
{ name: 'aws', check: %w[aws --version],
|
|
11
|
+
install_url: 'https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html' },
|
|
12
|
+
{ name: 'terraform', check: %w[terraform --version],
|
|
13
|
+
install_url: 'https://developer.hashicorp.com/terraform/install' },
|
|
14
|
+
{ name: 'ruby', check: %w[ruby --version], min_version: '3.3' },
|
|
15
|
+
{ name: 'bundler', check: %w[bundle --version], install_hint: 'gem install bundler' }
|
|
16
|
+
].freeze
|
|
17
|
+
|
|
18
|
+
def self.run(args)
|
|
19
|
+
verbose = args.include?('--verbose') || args.include?('-v')
|
|
20
|
+
|
|
21
|
+
if args.include?('--help') || args.include?('-h')
|
|
22
|
+
puts usage
|
|
23
|
+
exit 0
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
new(verbose: verbose).run
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.usage
|
|
30
|
+
<<~USAGE
|
|
31
|
+
Usage: belt doctor [options]
|
|
32
|
+
|
|
33
|
+
Check system dependencies and AWS configuration for Belt.
|
|
34
|
+
|
|
35
|
+
Options:
|
|
36
|
+
-v, --verbose Show detailed output for each check
|
|
37
|
+
-h, --help Show this help
|
|
38
|
+
USAGE
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def initialize(verbose: false)
|
|
42
|
+
@verbose = verbose
|
|
43
|
+
@issues = []
|
|
44
|
+
@warnings = []
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def run
|
|
48
|
+
puts "Belt Doctor\n"
|
|
49
|
+
puts "Checking your system for Belt prerequisites...\n\n"
|
|
50
|
+
|
|
51
|
+
check_tools
|
|
52
|
+
check_aws_credentials
|
|
53
|
+
check_aws_identity
|
|
54
|
+
|
|
55
|
+
puts ''
|
|
56
|
+
print_summary
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def check_tools
|
|
62
|
+
puts '── Tools ──'
|
|
63
|
+
REQUIRED_TOOLS.each do |tool|
|
|
64
|
+
check_tool(tool)
|
|
65
|
+
end
|
|
66
|
+
puts ''
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def check_tool(tool)
|
|
70
|
+
output, status = Open3.capture2e(*tool[:check])
|
|
71
|
+
|
|
72
|
+
if status.success?
|
|
73
|
+
version = extract_version(output)
|
|
74
|
+
if tool[:min_version] && version && Gem::Version.new(version) < Gem::Version.new(tool[:min_version])
|
|
75
|
+
print_warn(tool[:name], "found #{version}, need >= #{tool[:min_version]}")
|
|
76
|
+
@warnings << "#{tool[:name]} version #{version} is below minimum #{tool[:min_version]}"
|
|
77
|
+
else
|
|
78
|
+
print_ok(tool[:name], version ? version.to_s : output.strip.lines.first&.strip)
|
|
79
|
+
end
|
|
80
|
+
else
|
|
81
|
+
print_fail(tool[:name], 'not found')
|
|
82
|
+
hint = tool[:install_hint] || tool[:install_url]
|
|
83
|
+
puts " Install: #{hint}" if hint
|
|
84
|
+
@issues << "#{tool[:name]} is not installed"
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def check_aws_credentials
|
|
89
|
+
puts '── AWS Configuration ──'
|
|
90
|
+
|
|
91
|
+
# Check for credential sources
|
|
92
|
+
has_profile = ENV.fetch('AWS_PROFILE', nil) && !ENV['AWS_PROFILE'].empty?
|
|
93
|
+
has_env_keys = ENV.fetch('AWS_ACCESS_KEY_ID', nil) && !ENV['AWS_ACCESS_KEY_ID'].empty?
|
|
94
|
+
has_config_file = File.exist?(File.expand_path('~/.aws/config'))
|
|
95
|
+
has_credentials_file = File.exist?(File.expand_path('~/.aws/credentials'))
|
|
96
|
+
|
|
97
|
+
if has_profile
|
|
98
|
+
print_ok('AWS_PROFILE', ENV.fetch('AWS_PROFILE', nil))
|
|
99
|
+
elsif has_env_keys
|
|
100
|
+
print_ok('AWS_ACCESS_KEY_ID', 'set (environment variable)')
|
|
101
|
+
elsif has_credentials_file
|
|
102
|
+
print_ok('~/.aws/credentials', 'exists')
|
|
103
|
+
elsif has_config_file
|
|
104
|
+
print_ok('~/.aws/config', 'exists')
|
|
105
|
+
else
|
|
106
|
+
print_fail('AWS credentials', 'no credential source found')
|
|
107
|
+
puts ' Set AWS_PROFILE, or configure credentials via:'
|
|
108
|
+
puts ' aws configure sso # SSO (recommended)'
|
|
109
|
+
puts ' aws configure # access key + secret'
|
|
110
|
+
@issues << 'No AWS credentials configured'
|
|
111
|
+
return
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Check config file for profiles
|
|
115
|
+
return unless has_config_file && @verbose
|
|
116
|
+
|
|
117
|
+
profiles = parse_aws_config_profiles
|
|
118
|
+
puts " Profiles: #{profiles.join(', ')}" if profiles.any?
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def check_aws_identity
|
|
122
|
+
output, status = Open3.capture2e('aws', 'sts', 'get-caller-identity')
|
|
123
|
+
|
|
124
|
+
if status.success?
|
|
125
|
+
data = begin
|
|
126
|
+
JSON.parse(output)
|
|
127
|
+
rescue JSON::ParserError
|
|
128
|
+
{}
|
|
129
|
+
end
|
|
130
|
+
account = data['Account'] || '?'
|
|
131
|
+
arn = data['Arn'] || '?'
|
|
132
|
+
print_ok('Authentication', "account #{account}")
|
|
133
|
+
puts " ARN: #{arn}" if @verbose
|
|
134
|
+
else
|
|
135
|
+
error = output.strip
|
|
136
|
+
if error.include?('ExpiredToken') || error.include?('expired')
|
|
137
|
+
print_fail('Authentication', 'credentials expired')
|
|
138
|
+
puts ' Run: aws sso login'
|
|
139
|
+
@issues << 'AWS credentials are expired — run `aws sso login`'
|
|
140
|
+
elsif error.include?('InvalidClientTokenId') || error.include?('SignatureDoesNotMatch')
|
|
141
|
+
print_fail('Authentication', 'invalid credentials')
|
|
142
|
+
puts ' Your access key or secret is incorrect.'
|
|
143
|
+
puts ' Run: aws configure'
|
|
144
|
+
@issues << 'AWS credentials are invalid'
|
|
145
|
+
elsif error.include?('Could not connect') || error.include?('Unable to locate credentials')
|
|
146
|
+
print_fail('Authentication', 'unable to authenticate')
|
|
147
|
+
puts ' Run: aws configure sso # or set AWS_PROFILE'
|
|
148
|
+
@issues << 'Unable to authenticate with AWS'
|
|
149
|
+
else
|
|
150
|
+
print_fail('Authentication', 'failed')
|
|
151
|
+
puts " #{error.lines.first&.strip}" if error.length.positive?
|
|
152
|
+
@issues << 'AWS authentication failed'
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def print_summary
|
|
158
|
+
if @issues.empty? && @warnings.empty?
|
|
159
|
+
puts '✓ All checks passed — you\'re ready to belt!'
|
|
160
|
+
elsif @issues.empty?
|
|
161
|
+
puts "⚠ #{@warnings.size} warning#{'s' if @warnings.size > 1}:"
|
|
162
|
+
@warnings.each { |w| puts " • #{w}" }
|
|
163
|
+
else
|
|
164
|
+
puts "✗ #{@issues.size} issue#{'s' if @issues.size > 1} found:"
|
|
165
|
+
@issues.each { |i| puts " • #{i}" }
|
|
166
|
+
@warnings.each { |w| puts " • ⚠ #{w}" } if @warnings.any?
|
|
167
|
+
puts "\nFix the issues above, then run `belt doctor` again."
|
|
168
|
+
exit 1
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def print_ok(label, detail = nil)
|
|
173
|
+
msg = " ✓ #{label}"
|
|
174
|
+
msg += " — #{detail}" if detail
|
|
175
|
+
puts msg
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def print_fail(label, detail = nil)
|
|
179
|
+
msg = " ✗ #{label}"
|
|
180
|
+
msg += " — #{detail}" if detail
|
|
181
|
+
puts msg
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def print_warn(label, detail = nil)
|
|
185
|
+
msg = " ⚠ #{label}"
|
|
186
|
+
msg += " — #{detail}" if detail
|
|
187
|
+
puts msg
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def extract_version(output)
|
|
191
|
+
# Match common version patterns:
|
|
192
|
+
# "aws-cli/2.15.0" "Terraform v1.7.0" "Bundler version 2.5.0" "ruby 3.3.0"
|
|
193
|
+
match = output.match(%r{(?:version\s+|v|/|^ruby\s+)(\d+\.\d+(?:\.\d+)?)}i)
|
|
194
|
+
match ? match[1] : nil
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def parse_aws_config_profiles
|
|
198
|
+
config_path = File.expand_path('~/.aws/config')
|
|
199
|
+
return [] unless File.exist?(config_path)
|
|
200
|
+
|
|
201
|
+
File.readlines(config_path)
|
|
202
|
+
.filter_map { |line| line.match(/\[profile\s+(.+)\]/)&.captures&.first }
|
|
203
|
+
rescue StandardError
|
|
204
|
+
[]
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
@@ -26,11 +26,12 @@ module Belt
|
|
|
26
26
|
new(env_name).generate
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
-
def initialize(env_name, quiet: false, domain: nil)
|
|
29
|
+
def initialize(env_name, quiet: false, domain: nil, announce: true)
|
|
30
30
|
@env_name = env_name.downcase.gsub(/[^a-z0-9_-]/, '')
|
|
31
31
|
@app_name = detect_app_name
|
|
32
32
|
@domain = domain
|
|
33
33
|
@quiet = quiet
|
|
34
|
+
@announce = announce
|
|
34
35
|
end
|
|
35
36
|
|
|
36
37
|
def generate
|
|
@@ -41,19 +42,18 @@ module Belt
|
|
|
41
42
|
exit 1
|
|
42
43
|
end
|
|
43
44
|
|
|
44
|
-
puts "Creating environment: #{@env_name}"
|
|
45
|
+
puts "Creating environment: #{@env_name}" unless @quiet
|
|
45
46
|
FileUtils.mkdir_p(dest_dir)
|
|
46
47
|
|
|
47
48
|
templates.each do |template_name, dest_file|
|
|
48
49
|
dest_path = File.join(dest_dir, dest_file)
|
|
49
50
|
write_template(template_name, dest_path)
|
|
50
|
-
puts " create #{dest_path}"
|
|
51
|
+
puts " create #{dest_path}" unless @quiet
|
|
51
52
|
end
|
|
52
53
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
return if @quiet
|
|
54
|
+
return if @quiet || !@announce
|
|
56
55
|
|
|
56
|
+
puts "\n✓ Environment '#{@env_name}' created!"
|
|
57
57
|
puts "\nNext steps:"
|
|
58
58
|
puts " cd #{dest_dir}"
|
|
59
59
|
puts ' terraform init'
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require 'fileutils'
|
|
4
4
|
require 'erb'
|
|
5
5
|
require 'json'
|
|
6
|
+
require 'open3'
|
|
6
7
|
require_relative 'app_detection'
|
|
7
8
|
|
|
8
9
|
module Belt
|
|
@@ -28,8 +29,10 @@ module Belt
|
|
|
28
29
|
new(framework).generate
|
|
29
30
|
end
|
|
30
31
|
|
|
31
|
-
def initialize(framework)
|
|
32
|
+
def initialize(framework, quiet: false, announce: true)
|
|
32
33
|
@framework = framework
|
|
34
|
+
@quiet = quiet
|
|
35
|
+
@announce = announce
|
|
33
36
|
@app_name = detect_app_name
|
|
34
37
|
@module_name = @app_name.split(/[-_]/).map(&:capitalize).join
|
|
35
38
|
end
|
|
@@ -42,7 +45,7 @@ module Belt
|
|
|
42
45
|
exit 1
|
|
43
46
|
end
|
|
44
47
|
|
|
45
|
-
puts "Creating #{@framework} frontend application..."
|
|
48
|
+
puts "Creating #{@framework} frontend application..." unless @quiet
|
|
46
49
|
framework_dir = File.join(TEMPLATE_DIR, @framework)
|
|
47
50
|
|
|
48
51
|
unless Dir.exist?(framework_dir)
|
|
@@ -52,25 +55,35 @@ module Belt
|
|
|
52
55
|
|
|
53
56
|
copy_template(framework_dir, dest_dir)
|
|
54
57
|
|
|
55
|
-
puts "\n✓ Frontend (#{@framework}) created in frontend/"
|
|
58
|
+
puts "\n✓ Frontend (#{@framework}) created in frontend/" unless @quiet
|
|
56
59
|
|
|
57
60
|
install_dependencies(dest_dir)
|
|
58
61
|
setup_frontend_infra_for_existing_environments
|
|
59
62
|
|
|
63
|
+
return if @quiet || !@announce
|
|
64
|
+
|
|
60
65
|
puts "\nNext steps:"
|
|
61
66
|
puts ' belt server # Start local dev server'
|
|
62
67
|
puts ' belt deploy # Deploy everything to AWS'
|
|
63
68
|
end
|
|
64
69
|
|
|
70
|
+
def npm_ok?
|
|
71
|
+
@npm_ok != false
|
|
72
|
+
end
|
|
73
|
+
|
|
65
74
|
private
|
|
66
75
|
|
|
67
76
|
def install_dependencies(dest_dir)
|
|
68
|
-
puts "\n Installing npm dependencies..."
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
77
|
+
puts "\n Installing npm dependencies..." unless @quiet
|
|
78
|
+
_output, status = Open3.capture2e(
|
|
79
|
+
'npm', 'install', '--prefix', dest_dir, '--no-fund', '--no-audit'
|
|
80
|
+
)
|
|
81
|
+
if status.success?
|
|
82
|
+
puts ' ✓ npm dependencies installed' unless @quiet
|
|
83
|
+
@npm_ok = true
|
|
72
84
|
else
|
|
73
|
-
puts " ⚠ npm install failed — run `cd #{dest_dir} && npm install` manually"
|
|
85
|
+
puts " ⚠ npm install failed — run `cd #{dest_dir} && npm install` manually" unless @quiet
|
|
86
|
+
@npm_ok = false
|
|
74
87
|
end
|
|
75
88
|
end
|
|
76
89
|
|
|
@@ -79,16 +92,16 @@ module Belt
|
|
|
79
92
|
frontend_tf = File.join(module_dir, 'frontend.tf')
|
|
80
93
|
|
|
81
94
|
if File.exist?(frontend_tf)
|
|
82
|
-
puts " skip #{frontend_tf} (already exists)"
|
|
95
|
+
puts " skip #{frontend_tf} (already exists)" unless @quiet
|
|
83
96
|
return
|
|
84
97
|
end
|
|
85
98
|
|
|
86
99
|
return unless Dir.exist?(module_dir)
|
|
87
100
|
|
|
88
|
-
puts "\n Setting up frontend infrastructure..."
|
|
101
|
+
puts "\n Setting up frontend infrastructure..." unless @quiet
|
|
89
102
|
require_relative 'frontend_setup_command'
|
|
90
103
|
FrontendSetupCommand.new(nil, quiet: true).run
|
|
91
|
-
puts " create #{frontend_tf}"
|
|
104
|
+
puts " create #{frontend_tf}" unless @quiet
|
|
92
105
|
end
|
|
93
106
|
|
|
94
107
|
def copy_template(src_dir, dest_dir)
|
|
@@ -107,7 +120,7 @@ module Belt
|
|
|
107
120
|
else
|
|
108
121
|
FileUtils.cp(src, dest_path)
|
|
109
122
|
end
|
|
110
|
-
puts " create #{dest_path}"
|
|
123
|
+
puts " create #{dest_path}" unless @quiet
|
|
111
124
|
end
|
|
112
125
|
end
|
|
113
126
|
end
|
|
@@ -28,6 +28,7 @@ module Belt
|
|
|
28
28
|
def run
|
|
29
29
|
validate!
|
|
30
30
|
generate_frontend_tf
|
|
31
|
+
ensure_cloudfront_cors
|
|
31
32
|
return if @quiet
|
|
32
33
|
|
|
33
34
|
puts "\n✓ Frontend infrastructure generated in #{MODULE_DIR}!"
|
|
@@ -51,6 +52,46 @@ module Belt
|
|
|
51
52
|
File.write(dest, content)
|
|
52
53
|
puts " create #{dest}" unless @quiet
|
|
53
54
|
end
|
|
55
|
+
|
|
56
|
+
# Wire CloudFront origin into conveyor_belt frontend_urls so SPA→API CORS works.
|
|
57
|
+
# Handles common scaffold shapes so users never need the tutorial's manual CORS fix.
|
|
58
|
+
def ensure_cloudfront_cors
|
|
59
|
+
main_tf = File.join(MODULE_DIR, 'main.tf')
|
|
60
|
+
return unless File.exist?(main_tf)
|
|
61
|
+
|
|
62
|
+
content = File.read(main_tf)
|
|
63
|
+
return if content.include?('aws_cloudfront_distribution.frontend.domain_name')
|
|
64
|
+
|
|
65
|
+
replacement = lambda do |indent|
|
|
66
|
+
<<~TF.chomp
|
|
67
|
+
#{indent}# CloudFront first so SPA→API CORS works out of the box.
|
|
68
|
+
#{indent}frontend_urls = concat(
|
|
69
|
+
#{indent} ["https://${aws_cloudfront_distribution.frontend.domain_name}"],
|
|
70
|
+
#{indent} var.frontend_urls
|
|
71
|
+
#{indent})
|
|
72
|
+
TF
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# `frontend_urls = var.frontend_urls`
|
|
76
|
+
simple = /^(\s*)frontend_urls\s*=\s*var\.frontend_urls\s*$/
|
|
77
|
+
if content.match?(simple)
|
|
78
|
+
content = content.sub(simple) { replacement.call(Regexp.last_match(1)) }
|
|
79
|
+
File.write(main_tf, content)
|
|
80
|
+
puts " update #{main_tf} (CloudFront CORS)" unless @quiet
|
|
81
|
+
return
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# `frontend_urls = concat(var.frontend_urls)` or multi-line concat without CloudFront
|
|
85
|
+
concat_only = /^(\s*)frontend_urls\s*=\s*concat\(\s*\n?\s*var\.frontend_urls\s*\n?\s*\)\s*$/m
|
|
86
|
+
if content.match?(concat_only)
|
|
87
|
+
content = content.sub(concat_only) { replacement.call(Regexp.last_match(1)) }
|
|
88
|
+
File.write(main_tf, content)
|
|
89
|
+
puts " update #{main_tf} (CloudFront CORS)" unless @quiet
|
|
90
|
+
return
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
puts " skip #{main_tf} (add CloudFront to frontend_urls manually for CORS)" unless @quiet
|
|
94
|
+
end
|
|
54
95
|
end
|
|
55
96
|
end
|
|
56
97
|
end
|
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'
|