belt 0.0.6 → 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.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +4 -0
- data/exe/belt +6 -0
- data/lib/belt/action_router.rb +7 -1
- data/lib/belt/cli/app_detection.rb +16 -0
- data/lib/belt/cli/bucket_security.rb +122 -0
- data/lib/belt/cli/env_resolver.rb +15 -0
- data/lib/belt/cli/environment_command.rb +77 -0
- data/lib/belt/cli/frontend_command.rb +85 -0
- data/lib/belt/cli/frontend_deploy_command.rb +125 -0
- data/lib/belt/cli/frontend_setup_command.rb +64 -0
- data/lib/belt/cli/generate_command.rb +206 -0
- data/lib/belt/cli/new_command.rb +125 -0
- data/lib/belt/cli/setup_command.rb +261 -0
- data/lib/belt/cli/tables_command.rb +138 -0
- data/lib/belt/cli/terraform_command.rb +77 -0
- data/lib/belt/cli/views_command.rb +134 -0
- data/lib/belt/cli.rb +98 -0
- data/lib/belt/lambda_handler.rb +16 -0
- data/lib/belt/version.rb +1 -1
- data/lib/belt.rb +1 -1
- data/lib/templates/environment/backend.tf.erb +8 -0
- data/lib/templates/environment/main.tf.erb +42 -0
- data/lib/templates/environment/terraform.tfvars.erb +1 -0
- data/lib/templates/environment/variables.tf.erb +16 -0
- data/lib/templates/frontend/react/index.html.erb +12 -0
- data/lib/templates/frontend/react/package.json.erb +20 -0
- data/lib/templates/frontend/react/src/App.jsx +14 -0
- data/lib/templates/frontend/react/src/index.css +10 -0
- data/lib/templates/frontend/react/src/lib/apiClient.js.erb +19 -0
- data/lib/templates/frontend/react/src/main.jsx +10 -0
- data/lib/templates/frontend/react/src/pages/Home.jsx.erb +10 -0
- data/lib/templates/frontend/react/vite.config.js +8 -0
- data/lib/templates/frontend_infra/frontend.tf.erb +159 -0
- data/lib/templates/generate/controller.rb.erb +59 -0
- data/lib/templates/generate/model.rb.erb +20 -0
- data/lib/templates/new_app/AGENTS.md.erb +130 -0
- data/lib/templates/new_app/Gemfile.erb +6 -0
- data/lib/templates/new_app/README.md.erb +25 -0
- data/lib/templates/new_app/gitignore.erb +14 -0
- data/lib/templates/new_app/infrastructure/routes.tf.rb.erb +5 -0
- data/lib/templates/new_app/infrastructure/schema.tf.rb.erb +9 -0
- data/lib/templates/new_app/lambda/Gemfile.erb +7 -0
- data/lib/templates/new_app/lambda/api.rb.erb +22 -0
- data/lib/templates/new_app/lambda/controllers/application_controller.rb.erb +6 -0
- data/lib/templates/new_app/lambda/lib/routes/routes.rb.erb +11 -0
- data/lib/templates/new_app/lambda/models/application_record.rb.erb +6 -0
- data/lib/templates/new_app/lambda/models/concerns/timestampable.rb.erb +23 -0
- data/lib/templates/views/Edit.jsx.erb +38 -0
- data/lib/templates/views/Form.jsx.erb +34 -0
- data/lib/templates/views/Index.jsx.erb +39 -0
- data/lib/templates/views/New.jsx.erb +26 -0
- data/lib/templates/views/Show.jsx.erb +46 -0
- data.tar.gz.sig +0 -0
- metadata +51 -3
- metadata.gz.sig +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d587a60fbaef9f5755a1746b766d5fdc6e6bf6efbc34ee58e3901b0324fbc199
|
|
4
|
+
data.tar.gz: ac3bca34eb3552cf18693605b4c42ba11d11ea1d5437d201b6db838443951c28
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fdcd4a4c7efbce1ef89e394f86f3b3252307f4e4d1462ff8582bd0de6fb5ce9d3e7d34cceacc43dd3d50c3165ef1e7f01592b220dcddeb246d81c371fcb59281
|
|
7
|
+
data.tar.gz: 8ba38580f372f91d188aad8fc843435dc7f35ef90b7142027f9201317761f4de231d310ab58f5f5536400d6060925d425fcc8d208716381048f96bb6db15b574
|
checksums.yaml.gz.sig
CHANGED
|
Binary file
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.0.7
|
|
4
|
+
|
|
5
|
+
- Fixed `discover_gem_paths` to use `Gem.loaded_specs` instead of `Gem::Specification.each` — the latter silently returns nothing on Lambda's vendored bundle layout, causing gem controllers/models to not be found
|
|
6
|
+
|
|
3
7
|
## 0.0.5
|
|
4
8
|
|
|
5
9
|
- Eliminated regex from `Belt::ActionRouter` — uses pure segment-by-segment string comparison (resolves CodeQL alerts)
|
data/exe/belt
ADDED
data/lib/belt/action_router.rb
CHANGED
|
@@ -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,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
|