belt 0.0.7 → 0.1.0

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 (55) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/exe/belt +6 -0
  4. data/lib/belt/action_router.rb +7 -1
  5. data/lib/belt/cli/app_detection.rb +16 -0
  6. data/lib/belt/cli/bucket_security.rb +122 -0
  7. data/lib/belt/cli/env_resolver.rb +15 -0
  8. data/lib/belt/cli/environment_command.rb +77 -0
  9. data/lib/belt/cli/frontend_command.rb +85 -0
  10. data/lib/belt/cli/frontend_deploy_command.rb +125 -0
  11. data/lib/belt/cli/frontend_setup_command.rb +64 -0
  12. data/lib/belt/cli/generate_command.rb +206 -0
  13. data/lib/belt/cli/new_command.rb +125 -0
  14. data/lib/belt/cli/setup_command.rb +261 -0
  15. data/lib/belt/cli/tables_command.rb +138 -0
  16. data/lib/belt/cli/terraform_command.rb +77 -0
  17. data/lib/belt/cli/views_command.rb +134 -0
  18. data/lib/belt/cli.rb +98 -0
  19. data/lib/belt/lambda_handler.rb +16 -0
  20. data/lib/belt/version.rb +1 -1
  21. data/lib/templates/environment/backend.tf.erb +8 -0
  22. data/lib/templates/environment/main.tf.erb +42 -0
  23. data/lib/templates/environment/terraform.tfvars.erb +1 -0
  24. data/lib/templates/environment/variables.tf.erb +16 -0
  25. data/lib/templates/frontend/react/index.html.erb +12 -0
  26. data/lib/templates/frontend/react/package.json.erb +20 -0
  27. data/lib/templates/frontend/react/src/App.jsx +14 -0
  28. data/lib/templates/frontend/react/src/index.css +10 -0
  29. data/lib/templates/frontend/react/src/lib/apiClient.js.erb +19 -0
  30. data/lib/templates/frontend/react/src/main.jsx +10 -0
  31. data/lib/templates/frontend/react/src/pages/Home.jsx.erb +10 -0
  32. data/lib/templates/frontend/react/vite.config.js +8 -0
  33. data/lib/templates/frontend_infra/frontend.tf.erb +159 -0
  34. data/lib/templates/generate/controller.rb.erb +59 -0
  35. data/lib/templates/generate/model.rb.erb +20 -0
  36. data/lib/templates/new_app/AGENTS.md.erb +130 -0
  37. data/lib/templates/new_app/Gemfile.erb +6 -0
  38. data/lib/templates/new_app/README.md.erb +25 -0
  39. data/lib/templates/new_app/gitignore.erb +14 -0
  40. data/lib/templates/new_app/infrastructure/routes.tf.rb.erb +5 -0
  41. data/lib/templates/new_app/infrastructure/schema.tf.rb.erb +9 -0
  42. data/lib/templates/new_app/lambda/Gemfile.erb +7 -0
  43. data/lib/templates/new_app/lambda/api.rb.erb +22 -0
  44. data/lib/templates/new_app/lambda/controllers/application_controller.rb.erb +6 -0
  45. data/lib/templates/new_app/lambda/lib/routes/routes.rb.erb +11 -0
  46. data/lib/templates/new_app/lambda/models/application_record.rb.erb +6 -0
  47. data/lib/templates/new_app/lambda/models/concerns/timestampable.rb.erb +23 -0
  48. data/lib/templates/views/Edit.jsx.erb +38 -0
  49. data/lib/templates/views/Form.jsx.erb +34 -0
  50. data/lib/templates/views/Index.jsx.erb +39 -0
  51. data/lib/templates/views/New.jsx.erb +26 -0
  52. data/lib/templates/views/Show.jsx.erb +46 -0
  53. data.tar.gz.sig +0 -0
  54. metadata +51 -3
  55. metadata.gz.sig +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 14240a12050757c9936f1f57d941d98d23f79d0fc82b54f3c9d75217a5a00677
4
- data.tar.gz: d8c545b345c138d8d84ca2dc170ea99ad8b5404310620cdf17c8092263a2f7fc
3
+ metadata.gz: d587a60fbaef9f5755a1746b766d5fdc6e6bf6efbc34ee58e3901b0324fbc199
4
+ data.tar.gz: ac3bca34eb3552cf18693605b4c42ba11d11ea1d5437d201b6db838443951c28
5
5
  SHA512:
6
- metadata.gz: 6bf259163e8b4c50e4d544fc671f8ed5408b6996563392ff5301de7c5e26ac9098e149d0eeadae028fe455ef92e409e8c187130a6c12b2652673c77be7c6afae
7
- data.tar.gz: 93bd5dc850ad6bb17e771c9a12982b4b89f2df06b048404f0b66dec83d7cff74720ee2e0e9359b00fb85341834b0c774faf51539afbec5c9fcbf01815eb7cf7d
6
+ metadata.gz: fdcd4a4c7efbce1ef89e394f86f3b3252307f4e4d1462ff8582bd0de6fb5ce9d3e7d34cceacc43dd3d50c3165ef1e7f01592b220dcddeb246d81c371fcb59281
7
+ data.tar.gz: 8ba38580f372f91d188aad8fc843435dc7f35ef90b7142027f9201317761f4de231d310ab58f5f5536400d6060925d425fcc8d208716381048f96bb6db15b574
checksums.yaml.gz.sig CHANGED
Binary file
data/exe/belt ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative '../lib/belt/cli'
5
+
6
+ Belt::CLI.start(ARGV)
@@ -144,9 +144,15 @@ module Belt
144
144
  next unless File.exist?(full_path)
145
145
 
146
146
  require full_path
147
- # After requiring, try to find the constant
147
+ # After requiring, try to find the constant (top-level or namespaced)
148
148
  class_name = "#{controller_name.split(%r{[_/]}).map(&:capitalize).join}Controller"
149
149
  return Object.const_get(class_name) if Object.const_defined?(class_name)
150
+
151
+ # Try under namespace module (e.g., BrablogControllers::PostsController)
152
+ if Object.const_defined?(@namespace_module_name)
153
+ ns = Object.const_get(@namespace_module_name)
154
+ return ns.const_get(class_name) if ns.const_defined?(class_name)
155
+ end
150
156
  end
151
157
 
152
158
  raise Belt::ActionNotFound, "Controller not found: #{controller_name}"
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Belt
4
+ module CLI
5
+ module AppDetection
6
+ def detect_app_name
7
+ routes_file = 'infrastructure/routes.tf.rb'
8
+ if File.exist?(routes_file)
9
+ match = File.read(routes_file).match(/namespace :(\w+)/)
10
+ return match[1] if match
11
+ end
12
+ File.basename(Dir.pwd)
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Belt
4
+ module CLI
5
+ module BucketSecurity
6
+ def audit_bucket_security(bucket)
7
+ {
8
+ versioning: check_versioning(bucket),
9
+ encryption: check_encryption(bucket),
10
+ public_access_block: check_public_access_block(bucket),
11
+ tls_policy: check_tls_policy(bucket)
12
+ }
13
+ end
14
+
15
+ def print_security_audit(audit)
16
+ puts "\nSecurity audit:"
17
+ puts " #{audit[:versioning] ? 'āœ“' : 'āœ—'} Versioning"
18
+ puts " #{audit[:encryption] ? 'āœ“' : 'āœ—'} Encryption (AES-256)"
19
+ puts " #{audit[:public_access_block] ? 'āœ“' : 'āœ—'} Public access block"
20
+ puts " #{audit[:tls_policy] ? 'āœ“' : 'āœ—'} TLS-only policy"
21
+ end
22
+
23
+ def check_versioning(bucket)
24
+ output = safe_capture('aws', 's3api', 'get-bucket-versioning', '--bucket', bucket, '--output', 'json')
25
+ return false unless output
26
+
27
+ data = JSON.parse(output)
28
+ data['Status'] == 'Enabled'
29
+ rescue JSON::ParserError
30
+ false
31
+ end
32
+
33
+ def check_encryption(bucket)
34
+ output = safe_capture('aws', 's3api', 'get-bucket-encryption', '--bucket', bucket, '--output', 'json')
35
+ return false unless output
36
+
37
+ data = JSON.parse(output)
38
+ rules = data.dig('ServerSideEncryptionConfiguration', 'Rules') || []
39
+ rules.any? { |r| r.dig('ApplyServerSideEncryptionByDefault', 'SSEAlgorithm') }
40
+ rescue JSON::ParserError
41
+ false
42
+ end
43
+
44
+ def check_public_access_block(bucket)
45
+ output = safe_capture('aws', 's3api', 'get-public-access-block', '--bucket', bucket, '--output', 'json')
46
+ return false unless output
47
+
48
+ data = JSON.parse(output)
49
+ config = data['PublicAccessBlockConfiguration'] || {}
50
+ config['BlockPublicAcls'] && config['IgnorePublicAcls'] &&
51
+ config['BlockPublicPolicy'] && config['RestrictPublicBuckets']
52
+ rescue JSON::ParserError
53
+ false
54
+ end
55
+
56
+ def check_tls_policy(bucket)
57
+ output = safe_capture('aws', 's3api', 'get-bucket-policy', '--bucket', bucket, '--output', 'json')
58
+ return false unless output
59
+
60
+ data = JSON.parse(output)
61
+ policy = JSON.parse(data['Policy'])
62
+ statements = policy['Statement'] || []
63
+ statements.any? do |s|
64
+ s['Effect'] == 'Deny' &&
65
+ s.dig('Condition', 'Bool', 'aws:SecureTransport') == 'false'
66
+ end
67
+ rescue JSON::ParserError, TypeError
68
+ false
69
+ end
70
+
71
+ def harden_bucket(bucket, audit)
72
+ unless audit[:versioning]
73
+ enable_versioning(bucket)
74
+ puts ' enable versioning'
75
+ end
76
+ unless audit[:encryption]
77
+ enable_encryption(bucket)
78
+ puts ' enable AES-256 encryption'
79
+ end
80
+ unless audit[:public_access_block]
81
+ block_public_access(bucket)
82
+ puts ' enable public access block'
83
+ end
84
+ return if audit[:tls_policy]
85
+
86
+ apply_tls_policy(bucket)
87
+ puts ' enable TLS-only bucket policy'
88
+ end
89
+
90
+ private
91
+
92
+ def enable_versioning(bucket)
93
+ run!('aws', 's3api', 'put-bucket-versioning', '--bucket', bucket,
94
+ '--versioning-configuration', 'Status=Enabled')
95
+ end
96
+
97
+ def enable_encryption(bucket)
98
+ config = '{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"},"BucketKeyEnabled":true}]}'
99
+ run!('aws', 's3api', 'put-bucket-encryption', '--bucket', bucket,
100
+ '--server-side-encryption-configuration', config)
101
+ end
102
+
103
+ def block_public_access(bucket)
104
+ run!('aws', 's3api', 'put-public-access-block', '--bucket', bucket,
105
+ '--public-access-block-configuration',
106
+ 'BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true')
107
+ end
108
+
109
+ def apply_tls_policy(bucket)
110
+ policy = {
111
+ Version: '2012-10-17',
112
+ Statement: [{
113
+ Sid: 'DenyInsecureConnections', Effect: 'Deny', Principal: '*', Action: 's3:*',
114
+ Resource: ["arn:aws:s3:::#{bucket}", "arn:aws:s3:::#{bucket}/*"],
115
+ Condition: { Bool: { 'aws:SecureTransport' => 'false' } }
116
+ }]
117
+ }
118
+ run!('aws', 's3api', 'put-bucket-policy', '--bucket', bucket, '--policy', JSON.generate(policy))
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Belt
4
+ module CLI
5
+ module EnvResolver
6
+ def self.resolve(args)
7
+ if args.first && !args.first.start_with?('-')
8
+ args.shift
9
+ else
10
+ ENV.fetch('BELT_ENV', nil)
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'erb'
5
+ require_relative 'app_detection'
6
+
7
+ module Belt
8
+ module CLI
9
+ class EnvironmentCommand
10
+ TEMPLATE_DIR = File.expand_path('../../templates/environment', __dir__)
11
+
12
+ include AppDetection
13
+
14
+ def self.run(args)
15
+ env_name = args.shift
16
+
17
+ if env_name.nil? || env_name.empty?
18
+ puts 'Usage: belt generate environment <name>'
19
+ puts "\nExamples:"
20
+ puts ' belt generate environment dev01'
21
+ puts ' belt generate environment staging'
22
+ puts ' belt generate environment prod'
23
+ exit 1
24
+ end
25
+
26
+ new(env_name).generate
27
+ end
28
+
29
+ def initialize(env_name)
30
+ @env_name = env_name.downcase.gsub(/[^a-z0-9_-]/, '')
31
+ @app_name = detect_app_name
32
+ end
33
+
34
+ def generate
35
+ dest_dir = "infrastructure/#{@env_name}"
36
+
37
+ if Dir.exist?(dest_dir)
38
+ puts "Environment '#{@env_name}' already exists at #{dest_dir}/"
39
+ exit 1
40
+ end
41
+
42
+ puts "Creating environment: #{@env_name}"
43
+ FileUtils.mkdir_p(dest_dir)
44
+
45
+ templates.each do |template_name, dest_file|
46
+ dest_path = File.join(dest_dir, dest_file)
47
+ write_template(template_name, dest_path)
48
+ puts " create #{dest_path}"
49
+ end
50
+
51
+ puts "\nāœ“ Environment '#{@env_name}' created!"
52
+ puts "\nNext steps:"
53
+ puts " cd #{dest_dir}"
54
+ puts ' terraform init'
55
+ puts ' terraform plan'
56
+ puts ' terraform apply'
57
+ end
58
+
59
+ private
60
+
61
+ def templates
62
+ {
63
+ 'main.tf.erb' => 'main.tf',
64
+ 'backend.tf.erb' => 'backend.tf',
65
+ 'variables.tf.erb' => 'variables.tf',
66
+ 'terraform.tfvars.erb' => 'terraform.tfvars'
67
+ }
68
+ end
69
+
70
+ def write_template(template_name, dest_path)
71
+ template_path = File.join(TEMPLATE_DIR, template_name)
72
+ content = ERB.new(File.read(template_path), trim_mode: '-').result(binding)
73
+ File.write(dest_path, content)
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'erb'
5
+ require 'json'
6
+ require_relative 'app_detection'
7
+
8
+ module Belt
9
+ module CLI
10
+ class FrontendCommand
11
+ TEMPLATE_DIR = File.expand_path('../../templates/frontend', __dir__)
12
+ FRAMEWORKS = %w[react vue svelte].freeze
13
+
14
+ include AppDetection
15
+
16
+ def self.run(args)
17
+ framework = args.shift
18
+
19
+ if framework.nil? || !FRAMEWORKS.include?(framework)
20
+ puts "Usage: belt generate frontend <#{FRAMEWORKS.join('|')}>"
21
+ puts "\nScaffolds a frontend application with build tooling and API client."
22
+ puts "\nExamples:"
23
+ puts ' belt generate frontend react'
24
+ puts ' belt generate frontend vue'
25
+ exit 1
26
+ end
27
+
28
+ new(framework).generate
29
+ end
30
+
31
+ def initialize(framework)
32
+ @framework = framework
33
+ @app_name = detect_app_name
34
+ @module_name = @app_name.split(/[-_]/).map(&:capitalize).join
35
+ end
36
+
37
+ def generate
38
+ dest_dir = 'frontend'
39
+
40
+ if Dir.exist?(dest_dir) && !Dir.empty?(dest_dir)
41
+ puts "Directory 'frontend/' already exists and is not empty."
42
+ exit 1
43
+ end
44
+
45
+ puts "Creating #{@framework} frontend application..."
46
+ framework_dir = File.join(TEMPLATE_DIR, @framework)
47
+
48
+ unless Dir.exist?(framework_dir)
49
+ puts "āœ— Template not found for '#{@framework}'. Available: #{FRAMEWORKS.join(', ')}"
50
+ exit 1
51
+ end
52
+
53
+ copy_template(framework_dir, dest_dir)
54
+
55
+ puts "\nāœ“ Frontend (#{@framework}) created in frontend/"
56
+ puts "\nNext steps:"
57
+ puts ' cd frontend && npm install && npm run dev'
58
+ puts ' belt setup frontend <env> # Generate CloudFront + S3 infrastructure'
59
+ puts ' belt deploy frontend <env> # Build and deploy to AWS'
60
+ end
61
+
62
+ private
63
+
64
+ def copy_template(src_dir, dest_dir)
65
+ Dir.glob("#{src_dir}/**/*", File::FNM_DOTMATCH).each do |src|
66
+ next if File.directory?(src)
67
+ next if src.end_with?('/..') || src.end_with?('/.')
68
+
69
+ rel_path = src.sub("#{src_dir}/", '')
70
+ dest_path = File.join(dest_dir, rel_path.sub(/\.erb\z/, ''))
71
+
72
+ FileUtils.mkdir_p(File.dirname(dest_path))
73
+
74
+ if src.end_with?('.erb')
75
+ content = ERB.new(File.read(src), trim_mode: '-').result(binding)
76
+ File.write(dest_path, content)
77
+ else
78
+ FileUtils.cp(src, dest_path)
79
+ end
80
+ puts " create #{dest_path}"
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'shellwords'
4
+ require 'open3'
5
+ require_relative 'app_detection'
6
+ require_relative 'env_resolver'
7
+
8
+ module Belt
9
+ module CLI
10
+ class FrontendDeployCommand
11
+ include AppDetection
12
+
13
+ def self.run(args)
14
+ env = EnvResolver.resolve(args)
15
+
16
+ if env.nil?
17
+ puts 'Usage: belt deploy frontend <environment>'
18
+ puts "\nBuilds the frontend app and deploys to S3 + invalidates CloudFront."
19
+ puts 'You can also set BELT_ENV to skip the environment argument.'
20
+ puts "\nExamples:"
21
+ puts ' belt deploy frontend wups'
22
+ puts ' belt deploy frontend dev01'
23
+ puts ' BELT_ENV=wups belt deploy frontend'
24
+ exit 1
25
+ end
26
+
27
+ new(env).run
28
+ end
29
+
30
+ def initialize(env)
31
+ @env = env
32
+ @app_name = detect_app_name
33
+ @env_dir = "infrastructure/#{@env}"
34
+ end
35
+
36
+ def run
37
+ validate!
38
+ build_frontend
39
+ sync_to_s3
40
+ invalidate_cloudfront
41
+ url = fetch_frontend_url
42
+ puts "\nāœ… Frontend deployed to #{@env}!"
43
+ puts " #{url}" if url
44
+ end
45
+
46
+ private
47
+
48
+ def validate!
49
+ unless Dir.exist?('frontend')
50
+ abort 'Error: No frontend/ directory found. Run `belt generate frontend react` first.'
51
+ end
52
+ return if File.exist?('frontend/package.json')
53
+
54
+ abort 'Error: frontend/package.json not found.'
55
+ end
56
+
57
+ def build_frontend
58
+ puts 'šŸ“¦ Installing dependencies...'
59
+ install_cmd = File.exist?('frontend/package-lock.json') ? %w[npm ci] : %w[npm install]
60
+ run!(*install_cmd, chdir: 'frontend')
61
+
62
+ puts 'šŸ—ļø Building frontend...'
63
+ api_url = fetch_api_url
64
+ env = api_url ? { 'VITE_API_URL' => api_url } : {}
65
+ run!(env, 'npm', 'run', 'build', chdir: 'frontend')
66
+ end
67
+
68
+ def sync_to_s3
69
+ bucket = fetch_bucket_name
70
+ abort "Error: Could not determine S3 bucket. Run `belt apply #{@env}` first." unless bucket
71
+
72
+ puts "ā˜ļø Deploying to S3... (#{bucket})"
73
+
74
+ # Hashed assets get immutable cache headers
75
+ run!('aws', 's3', 'sync', 'frontend/dist/', "s3://#{bucket}", '--delete',
76
+ '--size-only', '--cache-control', 'public, max-age=31536000, immutable',
77
+ '--exclude', 'index.html')
78
+
79
+ # index.html always revalidates
80
+ run!('aws', 's3', 'cp', 'frontend/dist/index.html', "s3://#{bucket}/index.html",
81
+ '--cache-control', 'no-cache')
82
+ end
83
+
84
+ def invalidate_cloudfront
85
+ dist_id = fetch_distribution_id
86
+ unless dist_id
87
+ puts 'āš ļø No CloudFront distribution found (skipping cache invalidation)'
88
+ return
89
+ end
90
+
91
+ puts 'šŸ”„ Invalidating CloudFront cache...'
92
+ run!('aws', 'cloudfront', 'create-invalidation', '--distribution-id', dist_id, '--paths', '/*',
93
+ out: File::NULL)
94
+ puts 'āœ… CloudFront cache invalidated'
95
+ end
96
+
97
+ def fetch_api_url
98
+ output, status = Open3.capture2('terraform', 'output', '-raw', 'api_url', chdir: @env_dir)
99
+ status.success? && !output.strip.empty? ? output.strip : nil
100
+ end
101
+
102
+ def fetch_bucket_name
103
+ output, status = Open3.capture2('terraform', 'output', '-raw', 'frontend_bucket_name', chdir: @env_dir)
104
+ status.success? && !output.strip.empty? ? output.strip : nil
105
+ end
106
+
107
+ def fetch_distribution_id
108
+ output, status = Open3.capture2('terraform', 'output', '-raw', 'frontend_distribution_id', chdir: @env_dir)
109
+ status.success? && !output.strip.empty? ? output.strip : nil
110
+ end
111
+
112
+ def fetch_frontend_url
113
+ output, status = Open3.capture2('terraform', 'output', '-raw', 'frontend_url', chdir: @env_dir)
114
+ status.success? && !output.strip.empty? ? output.strip : nil
115
+ end
116
+
117
+ def run!(*args, **)
118
+ env = args.first.is_a?(Hash) ? args.shift : {}
119
+ return if system(env, *args, **)
120
+
121
+ abort "\nāœ— Command failed: #{args.shelljoin}"
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'erb'
5
+ require_relative 'app_detection'
6
+ require_relative 'env_resolver'
7
+
8
+ module Belt
9
+ module CLI
10
+ class FrontendSetupCommand
11
+ TEMPLATE_DIR = File.expand_path('../../templates/frontend_infra', __dir__)
12
+
13
+ include AppDetection
14
+
15
+ def self.run(args)
16
+ env = EnvResolver.resolve(args)
17
+
18
+ if env.nil?
19
+ puts 'Usage: belt setup frontend <environment>'
20
+ puts "\nGenerates S3 + CloudFront Terraform for frontend hosting."
21
+ puts 'You can also set BELT_ENV to skip the environment argument.'
22
+ puts "\nExamples:"
23
+ puts ' belt setup frontend wups'
24
+ puts ' belt setup frontend dev01'
25
+ puts ' BELT_ENV=wups belt setup frontend'
26
+ exit 1
27
+ end
28
+
29
+ new(env).run
30
+ end
31
+
32
+ def initialize(env)
33
+ @env = env
34
+ @app_name = detect_app_name
35
+ @env_dir = "infrastructure/#{@env}"
36
+ end
37
+
38
+ def run
39
+ validate!
40
+ generate_frontend_tf
41
+ puts "\nāœ“ Frontend infrastructure generated for '#{@env}'!"
42
+ puts "\nRun `belt apply #{@env}` to create the S3 bucket and CloudFront distribution."
43
+ puts "Then `belt deploy frontend #{@env}` to build and deploy."
44
+ end
45
+
46
+ private
47
+
48
+ def validate!
49
+ return if Dir.exist?(@env_dir)
50
+
51
+ abort "Error: Environment '#{@env}' not found at #{@env_dir}/.\n" \
52
+ "Create it with: belt generate environment #{@env}"
53
+ end
54
+
55
+ def generate_frontend_tf
56
+ dest = File.join(@env_dir, 'frontend.tf')
57
+ template_path = File.join(TEMPLATE_DIR, 'frontend.tf.erb')
58
+ content = ERB.new(File.read(template_path), trim_mode: '-').result(binding)
59
+ File.write(dest, content)
60
+ puts " create #{dest}"
61
+ end
62
+ end
63
+ end
64
+ end