belt 0.2.9 → 0.2.11

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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +119 -0
  3. data/README.md +217 -8
  4. data/lib/belt/action_router.rb +33 -5
  5. data/lib/belt/assets/welcome.css +119 -47
  6. data/lib/belt/cli/bucket_security.rb +10 -4
  7. data/lib/belt/cli/deploy_command.rb +48 -0
  8. data/lib/belt/cli/destroy_command.rb +119 -10
  9. data/lib/belt/cli/doctor_command.rb +208 -0
  10. data/lib/belt/cli/environment_command.rb +38 -6
  11. data/lib/belt/cli/frontend_command.rb +25 -12
  12. data/lib/belt/cli/frontend_setup_command.rb +41 -0
  13. data/lib/belt/cli/new_command.rb +106 -38
  14. data/lib/belt/cli/path_gem_materializer.rb +141 -0
  15. data/lib/belt/cli/plugin_command.rb +221 -0
  16. data/lib/belt/cli/setup_command.rb +35 -10
  17. data/lib/belt/cli.rb +8 -0
  18. data/lib/belt/configuration.rb +30 -0
  19. data/lib/belt/controllers/welcome_controller.rb +69 -2
  20. data/lib/belt/helpers/cors_origin.rb +19 -2
  21. data/lib/belt/helpers/response.rb +48 -6
  22. data/lib/belt/http_status.rb +89 -0
  23. data/lib/belt/lambda_handler.rb +14 -5
  24. data/lib/belt/rendering.rb +1 -0
  25. data/lib/belt/version.rb +1 -1
  26. data/lib/belt/views/welcome/show.html.erb +31 -31
  27. data/lib/belt.rb +16 -0
  28. data/lib/belt_controller/base.rb +24 -1
  29. data/lib/belt_controller/implicit_response.rb +104 -0
  30. data/lib/templates/environment/backend.tf.erb +1 -1
  31. data/lib/templates/frontend/react/src/index.css +4 -4
  32. data/lib/templates/frontend/react/src/lib/apiClient.js.erb +20 -3
  33. data/lib/templates/frontend/react/src/pages/Home.jsx.erb +283 -4
  34. data/lib/templates/module/main.tf.erb +13 -3
  35. data/lib/templates/new_app/config/routes.tf.rb.erb +2 -1
  36. data/lib/templates/plugin/AGENTS.md.erb +135 -0
  37. data/lib/templates/plugin/CHANGELOG.md.erb +5 -0
  38. data/lib/templates/plugin/Gemfile.erb +11 -0
  39. data/lib/templates/plugin/LICENSE.erb +21 -0
  40. data/lib/templates/plugin/README.md.erb +63 -0
  41. data/lib/templates/plugin/Rakefile.erb +7 -0
  42. data/lib/templates/plugin/gemspec.erb +26 -0
  43. data/lib/templates/plugin/gitignore.erb +11 -0
  44. data/lib/templates/plugin/lib/belt/generators/generator.rb.erb +122 -0
  45. data/lib/templates/plugin/lib/belt/module/configuration.rb.erb +15 -0
  46. data/lib/templates/plugin/lib/belt/module/version.rb.erb +7 -0
  47. data/lib/templates/plugin/lib/belt/module.rb.erb +27 -0
  48. data/lib/templates/plugin/lib/entry.rb.erb +3 -0
  49. data/lib/templates/plugin/rspec.erb +3 -0
  50. data/lib/templates/plugin/spec/configuration_spec.rb.erb +17 -0
  51. data/lib/templates/plugin/spec/spec_helper.rb.erb +19 -0
  52. metadata +37 -1
@@ -71,20 +71,26 @@ module Belt
71
71
  def harden_bucket(bucket, audit)
72
72
  unless audit[:versioning]
73
73
  enable_versioning(bucket)
74
- puts ' enable versioning'
74
+ say_security ' enable versioning'
75
75
  end
76
76
  unless audit[:encryption]
77
77
  enable_encryption(bucket)
78
- puts ' enable AES-256 encryption'
78
+ say_security ' enable AES-256 encryption'
79
79
  end
80
80
  unless audit[:public_access_block]
81
81
  block_public_access(bucket)
82
- puts ' enable public access block'
82
+ say_security ' enable public access block'
83
83
  end
84
84
  return if audit[:tls_policy]
85
85
 
86
86
  apply_tls_policy(bucket)
87
- puts ' enable TLS-only bucket policy'
87
+ say_security ' enable TLS-only bucket policy'
88
+ end
89
+
90
+ def say_security(message)
91
+ return if respond_to?(:say, true) && @quiet
92
+
93
+ puts message
88
94
  end
89
95
 
90
96
  private
@@ -9,6 +9,7 @@ require_relative 'terraform_command'
9
9
  require_relative 'backup_config'
10
10
  require_relative 'backup_runner'
11
11
  require_relative 'environment_config'
12
+ require_relative 'path_gem_materializer'
12
13
 
13
14
  module Belt
14
15
  module CLI
@@ -150,6 +151,7 @@ module Belt
150
151
  puts "belt → deploying #{@env} (in #{env_dir}/)\n\n"
151
152
 
152
153
  ensure_lockfile_consistent!
154
+ warn_active_path_gems!
153
155
  generate_routes_if_needed
154
156
  run_backups unless @skip_backup
155
157
 
@@ -457,9 +459,55 @@ module Belt
457
459
  FileUtils.cp(gemfile, build_dir) if File.exist?(gemfile)
458
460
  FileUtils.cp(lockfile, build_dir) if File.exist?(lockfile)
459
461
 
462
+ copy_vendor_cache(build_dir)
463
+ materialize_path_gems!(build_dir)
464
+
460
465
  puts ' šŸ“ Copied handlers, controllers, models, lib, config'
461
466
  end
462
467
 
468
+ # Stage prebuilt .gem files so Docker `bundle install` can install unreleased
469
+ # gems as normal package installs (with specifications/), not path/git layouts.
470
+ # Prefer project-root vendor/cache (next to Gemfile — Bundler's natural path);
471
+ # fall back to lambda/vendor/cache for older layouts.
472
+ def copy_vendor_cache(build_dir)
473
+ cache_dir = [
474
+ File.join(@project_root, 'vendor', 'cache'),
475
+ File.join(@project_root, 'lambda', 'vendor', 'cache')
476
+ ].find { |dir| Dir.exist?(dir) && !Dir.empty?(dir) }
477
+ return unless cache_dir
478
+
479
+ dest = File.join(build_dir, 'vendor', 'cache')
480
+ FileUtils.mkdir_p(dest)
481
+ FileUtils.cp_r(Dir.glob(File.join(cache_dir, '*')), dest)
482
+ gem_count = Dir.glob(File.join(dest, '*.gem')).size
483
+ puts " šŸ“¦ Copied vendor/cache (#{gem_count} local gem(s))"
484
+ end
485
+
486
+ # path: gems install under bundler/gems/ with no specifications/ — Lambda's
487
+ # bare `require 'belt'` can't see them. Build real .gem files into the
488
+ # package's vendor/cache and pin versions in the *build* Gemfile/lock only.
489
+ def materialize_path_gems!(build_dir)
490
+ gems = PathGemMaterializer.materialize!(build_dir, project_root: @project_root)
491
+ return if gems.empty?
492
+
493
+ puts " šŸ”§ Materialized path gem(s) → vendor/cache: #{gems.join(', ')}"
494
+ end
495
+
496
+ # path: gems need host-side materialize before Docker install. --rebuild
497
+ # always does it. Full terraform apply needs a conveyor-belt build that
498
+ # includes path-gem materialize (discord-vendor-cache-gemfile-parent+).
499
+ def warn_active_path_gems!
500
+ lockfile = File.join(@project_root, 'Gemfile.lock')
501
+ return unless File.exist?(lockfile)
502
+ return unless File.read(lockfile).match?(/^PATH\n/)
503
+
504
+ puts ' ⚠ Gemfile.lock has PATH gems (path: in Gemfile).'
505
+ puts " Safe now: belt deploy #{@env} --rebuild (always materializes)."
506
+ puts ' Full terraform apply needs conveyor-belt with path-gem materialize.'
507
+ puts ' Or pin a version + drop a built .gem in vendor/cache/'
508
+ puts ''
509
+ end
510
+
463
511
  def build_gems(build_dir)
464
512
  puts ' 🐳 Building gems in Docker (Lambda-compatible)...'
465
513
 
@@ -44,10 +44,11 @@ module Belt
44
44
  when 'environment'
45
45
  name = args.shift
46
46
  if name.nil? || name.empty?
47
- puts 'Usage: belt destroy environment <name>'
47
+ puts 'Usage: belt destroy environment <name> [--force] [--skip-terraform]'
48
48
  exit 1
49
49
  end
50
- new(generator, name, []).destroy
50
+ flags = parse_environment_flags(args)
51
+ new(generator, name, [], **flags).destroy
51
52
  when 'frontend'
52
53
  new(generator, nil, []).destroy
53
54
  when 'views'
@@ -67,6 +68,19 @@ module Belt
67
68
  end
68
69
  end
69
70
 
71
+ def self.parse_environment_flags(args)
72
+ flags = { force: false, skip_terraform: false }
73
+ args.each do |arg|
74
+ case arg
75
+ when '--force', '-f'
76
+ flags[:force] = true
77
+ when '--skip-terraform'
78
+ flags[:skip_terraform] = true
79
+ end
80
+ end
81
+ flags
82
+ end
83
+
70
84
  def self.parse_field(arg)
71
85
  name, type = arg.split(':', 2)
72
86
  { name: name, type: type || 'string' }
@@ -84,26 +98,37 @@ module Belt
84
98
  resource Alias for scaffold
85
99
  model Remove an ActiveItem model
86
100
  controller Remove a controller
87
- environment Remove a deployment environment directory
101
+ environment Remove a deployment environment and tear down infrastructure
88
102
  frontend Remove the frontend/ directory
89
103
  views Remove React pages for a resource
90
104
 
105
+ Environment options:
106
+ --force, -f Skip all prompts (CI mode)
107
+ --skip-terraform Delete local files without running terraform destroy
108
+
91
109
  Examples:
92
110
  belt d scaffold post title:string body:text status:string
93
111
  belt d model user
94
112
  belt d controller comments
95
113
  belt d environment staging
114
+ belt d environment dev --skip-terraform
115
+ belt d environment dev --force
96
116
  belt d frontend
97
117
  belt d views post
98
118
 
99
119
  ⚠ This is destructive. Files will be permanently deleted.
120
+
121
+ For environments: if terraform state is detected, you'll be prompted to run
122
+ `terraform destroy` first to tear down cloud resources before removing files.
100
123
  HELP
101
124
  end
102
125
 
103
- def initialize(generator, name, fields)
126
+ def initialize(generator, name, fields, force: false, skip_terraform: false)
104
127
  @generator = generator
105
128
  @name = name&.downcase&.gsub(/[^a-z0-9_]/, '_')
106
129
  @fields = fields
130
+ @force = force
131
+ @skip_terraform = skip_terraform
107
132
  @app_name = detect_namespace
108
133
  @singular_name = @name ? Belt::Inflector.singularize(@name) : nil
109
134
  @resource_name = @singular_name ? Belt::Inflector.pluralize(@singular_name) : nil
@@ -149,15 +174,99 @@ module Belt
149
174
 
150
175
  def destroy_environment
151
176
  dir = "infrastructure/#{@name}"
152
- if Dir.exist?(dir)
153
- FileUtils.rm_rf(dir)
154
- @removed << dir
155
- puts " remove #{dir}/"
156
- puts "\nāœ“ Environment '#{@name}' destroyed!"
157
- else
177
+
178
+ unless Dir.exist?(dir)
158
179
  puts "āœ— Environment '#{@name}' not found at #{dir}/"
159
180
  exit 1
160
181
  end
182
+
183
+ # Check if terraform state exists (infra may still be live)
184
+ if !@skip_terraform && terraform_state_exists?(dir)
185
+ puts "⚠ Environment '#{@name}' appears to have active infrastructure."
186
+ puts " Terraform state was found — resources may still be running.\n\n"
187
+
188
+ if @force
189
+ puts ' --force passed, skipping terraform destroy.'
190
+ else
191
+ puts ' Options:'
192
+ puts ' 1) Run `terraform destroy` to tear down infrastructure first (recommended)'
193
+ puts ' 2) Skip terraform and just delete the local files (--skip-terraform)'
194
+ puts " 3) Cancel\n\n"
195
+
196
+ print " Run terraform destroy for '#{@name}'? [y/N/skip] "
197
+ response = $stdin.gets&.strip&.downcase
198
+
199
+ case response
200
+ when 'y', 'yes'
201
+ run_terraform_destroy(dir)
202
+ when 'skip', 's'
203
+ puts ' Skipping terraform destroy.'
204
+ else
205
+ puts 'Cancelled.'
206
+ exit 0
207
+ end
208
+ end
209
+ end
210
+
211
+ # Final confirmation before deleting files
212
+ unless @force
213
+ print "\nPermanently delete #{dir}/? [y/N] "
214
+ response = $stdin.gets&.strip&.downcase
215
+ unless response&.start_with?('y')
216
+ puts 'Cancelled.'
217
+ exit 0
218
+ end
219
+ end
220
+
221
+ FileUtils.rm_rf(dir)
222
+ @removed << dir
223
+ puts " remove #{dir}/"
224
+ puts "\nāœ“ Environment '#{@name}' destroyed!"
225
+ end
226
+
227
+ def terraform_state_exists?(dir)
228
+ # Check for local .terraform directory (initialized state)
229
+ return true if Dir.exist?(File.join(dir, '.terraform'))
230
+
231
+ # Check if backend.tf exists (remote state configured)
232
+ backend_file = File.join(dir, 'backend.tf')
233
+ return false unless File.exist?(backend_file)
234
+
235
+ # Try to query remote state — if terraform is initialized and state exists,
236
+ # the environment likely has live resources
237
+ Dir.chdir(dir) do
238
+ # Quick check: does `terraform show` return anything?
239
+ output = `terraform show -no-color 2>&1`
240
+ Process.last_status.success? && !output.strip.empty? && !output.include?('No state')
241
+ end
242
+ rescue StandardError
243
+ # If we can't determine state, assume it might exist and warn
244
+ true
245
+ end
246
+
247
+ def run_terraform_destroy(dir)
248
+ puts "\n━━━ terraform destroy (#{@name}) ━━━"
249
+
250
+ Dir.chdir(dir) do
251
+ # Initialize if needed
252
+ unless Dir.exist?('.terraform')
253
+ puts ' Initializing terraform...'
254
+ unless system('terraform', 'init', '-input=false')
255
+ puts "\nāœ— terraform init failed. You may need to destroy manually:"
256
+ puts " cd #{dir} && terraform init && terraform destroy"
257
+ exit 1
258
+ end
259
+ end
260
+
261
+ # Run destroy with auto-approve (user already confirmed)
262
+ unless system('terraform', 'destroy', '-auto-approve')
263
+ puts "\nāœ— terraform destroy failed."
264
+ puts ' Infrastructure may still be running. Fix and retry, or use --skip-terraform.'
265
+ exit 1
266
+ end
267
+ end
268
+
269
+ puts ' āœ“ Infrastructure destroyed.'
161
270
  end
162
271
 
163
272
  def destroy_frontend
@@ -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,13 @@ 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
35
+ @state_bucket = resolve_state_bucket
34
36
  end
35
37
 
36
38
  def generate
@@ -41,19 +43,18 @@ module Belt
41
43
  exit 1
42
44
  end
43
45
 
44
- puts "Creating environment: #{@env_name}"
46
+ puts "Creating environment: #{@env_name}" unless @quiet
45
47
  FileUtils.mkdir_p(dest_dir)
46
48
 
47
49
  templates.each do |template_name, dest_file|
48
50
  dest_path = File.join(dest_dir, dest_file)
49
51
  write_template(template_name, dest_path)
50
- puts " create #{dest_path}"
52
+ puts " create #{dest_path}" unless @quiet
51
53
  end
52
54
 
53
- puts "\nāœ“ Environment '#{@env_name}' created!"
54
-
55
- return if @quiet
55
+ return if @quiet || !@announce
56
56
 
57
+ puts "\nāœ“ Environment '#{@env_name}' created!"
57
58
  puts "\nNext steps:"
58
59
  puts " cd #{dest_dir}"
59
60
  puts ' terraform init'
@@ -80,6 +81,37 @@ module Belt
80
81
  content = ERB.new(File.read(template_path), trim_mode: '-').result(binding)
81
82
  File.write(dest_path, content)
82
83
  end
84
+
85
+ # Resolve the state bucket name to use in backend.tf.
86
+ # Priority: existing sibling backend.tf → AWS account ID → bare placeholder.
87
+ def resolve_state_bucket
88
+ bucket_from_sibling || bucket_from_aws || 'belt-terraform-state'
89
+ end
90
+
91
+ def bucket_from_sibling
92
+ Dir.glob('infrastructure/*/backend.tf').each do |f|
93
+ match = File.read(f).match(/bucket\s*=\s*"([^"]+)"/)
94
+ next unless match
95
+ # Skip the bare placeholder — it means state wasn't set up yet
96
+ return match[1] unless match[1] == 'belt-terraform-state'
97
+ end
98
+ nil
99
+ end
100
+
101
+ def bucket_from_aws
102
+ require 'open3'
103
+ output, status = Open3.capture2e('aws', 'sts', 'get-caller-identity')
104
+ return nil unless status.success?
105
+
106
+ data = begin
107
+ JSON.parse(output)
108
+ rescue StandardError
109
+ nil
110
+ end
111
+ return nil unless data&.dig('Account')
112
+
113
+ "belt-terraform-state-#{data['Account']}"
114
+ end
83
115
  end
84
116
  end
85
117
  end
@@ -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