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.
@@ -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
- puts "\n✓ Environment '#{@env_name}' created!"
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
- success = system('npm', 'install', '--prefix', dest_dir, '--loglevel', 'error')
70
- if success
71
- puts ' ✓ Dependencies installed'
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
@@ -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'