belt 0.1.8 → 0.1.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 +10 -0
- data/lib/belt/cli/deploy_command.rb +40 -0
- data/lib/belt/cli/destroy_command.rb +8 -28
- data/lib/belt/cli/environment_command.rb +4 -12
- data/lib/belt/cli/frontend_command.rb +11 -13
- data/lib/belt/cli/frontend_deploy_command.rb +21 -13
- data/lib/belt/cli/frontend_env_command.rb +110 -0
- data/lib/belt/cli/frontend_env_map.rb +188 -0
- data/lib/belt/cli/frontend_setup_command.rb +14 -55
- data/lib/belt/cli/generate_command.rb +73 -10
- data/lib/belt/cli/new_command.rb +82 -33
- data/lib/belt/cli/server_command.rb +25 -7
- data/lib/belt/cli/setup_command.rb +2 -8
- data/lib/belt/cli/tables_command.rb +16 -21
- data/lib/belt/cli.rb +6 -0
- data/lib/belt/version.rb +1 -1
- data/lib/templates/environment/backend.tf.erb +2 -2
- data/lib/templates/environment/main.tf.erb +14 -18
- data/lib/templates/environment/outputs.tf.erb +28 -3
- data/lib/templates/environment/terraform.tfvars.erb +5 -0
- data/lib/templates/environment/variables.tf.erb +6 -0
- data/lib/templates/frontend/react/env.yml.example +13 -0
- data/lib/templates/module/dns.tf.erb +83 -0
- data/lib/templates/module/frontend.tf.erb +179 -0
- data/lib/templates/module/main.tf.erb +42 -0
- data/lib/templates/module/outputs.tf.erb +24 -0
- data/lib/templates/module/variables.tf.erb +27 -0
- data/lib/templates/views/Form.jsx.erb +63 -7
- data/lib/templates/views/Index.jsx.erb +41 -10
- data/lib/templates/views/Show.jsx.erb +12 -2
- metadata +9 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c48b3087bb87bb86b2111cc243cf57d889e8f3d94fb25f1b023e5ba3dabf4973
|
|
4
|
+
data.tar.gz: 53b43e092412aaab0a0fae0e531d7d7ffb07c7a69e19c3e7e7480cf19753184f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2443a472879d551084bd9f6a72b468e6dab20105ae84b3d906cbc33d42c8af6cc32229468300b00f9788a19c324b747f6cd0872d7d652c8d17e96a20cd2c95a6
|
|
7
|
+
data.tar.gz: a6dd1cc9483792736335058a41b983df0380d8d48ee180b47e3a619a92d7d0a47fcd33af811cff20d7c451d1a783a8ccc49c3934dae60155c7aa6d039b8b7f27
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.1.10
|
|
6
|
+
|
|
7
|
+
### Frontend env map
|
|
8
|
+
|
|
9
|
+
- Declarative map (`frontend/env.yml` or `.belt/frontend_env.yml`) maps process env names → terraform output names (framework-agnostic: `VITE_*`, `REACT_APP_*`, `NEXT_PUBLIC_*`, etc.)
|
|
10
|
+
- `belt deploy frontend <env>` injects mapped vars into `npm run build` process env
|
|
11
|
+
- `belt frontend env <env>` smart-merges mapped keys into `frontend/.env` (preserves unmapped keys/comments; missing TF outputs warn and do not clobber)
|
|
12
|
+
- No map → previous default: only `VITE_API_URL` ← `api_url`
|
|
13
|
+
- `belt server` uses the same map for local process env
|
|
14
|
+
|
|
5
15
|
## 0.1.4
|
|
6
16
|
|
|
7
17
|
### Bug fixes
|
|
@@ -503,8 +503,48 @@ module Belt
|
|
|
503
503
|
output.each_line do |line|
|
|
504
504
|
puts " #{line}"
|
|
505
505
|
end
|
|
506
|
+
|
|
507
|
+
# Show DNS guidance if name_servers are present and this looks like a first deploy
|
|
508
|
+
print_dns_guidance if output.include?('name_servers')
|
|
506
509
|
end
|
|
507
510
|
end
|
|
511
|
+
|
|
512
|
+
def print_dns_guidance
|
|
513
|
+
# Check if there's a marker file indicating DNS guidance was already shown
|
|
514
|
+
marker = File.join(@infra_dir, @env, '.dns_configured')
|
|
515
|
+
return if File.exist?(marker)
|
|
516
|
+
|
|
517
|
+
domain = detect_domain_from_tfvars
|
|
518
|
+
return unless domain
|
|
519
|
+
|
|
520
|
+
puts ''
|
|
521
|
+
puts ' ┌─────────────────────────────────────────────────────────────┐'
|
|
522
|
+
puts ' │ DNS SETUP REQUIRED │'
|
|
523
|
+
puts ' ├─────────────────────────────────────────────────────────────┤'
|
|
524
|
+
puts ' │ Point your domain to the name_servers shown above. │'
|
|
525
|
+
puts ' │ │'
|
|
526
|
+
puts ' │ • External registrar (GoDaddy, Namecheap, etc.): │'
|
|
527
|
+
puts ' │ Update nameservers in your registrar\'s DNS settings. │'
|
|
528
|
+
puts ' │ │'
|
|
529
|
+
puts ' │ • Domain in AWS Route53 Registered Domains: │'
|
|
530
|
+
puts ' │ Route53 → Registered Domains → Name servers → Edit. │'
|
|
531
|
+
puts ' │ │'
|
|
532
|
+
puts " │ Verify: dig +short NS #{domain.ljust(38)}│"
|
|
533
|
+
puts ' │ │'
|
|
534
|
+
puts ' │ This message won\'t appear on subsequent deploys. │'
|
|
535
|
+
puts ' └─────────────────────────────────────────────────────────────┘'
|
|
536
|
+
|
|
537
|
+
# Create the marker so we don't nag on every deploy
|
|
538
|
+
File.write(marker, "DNS guidance shown at #{Time.now}\n")
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
def detect_domain_from_tfvars
|
|
542
|
+
tfvars_file = File.join(@infra_dir, @env, 'terraform.tfvars')
|
|
543
|
+
return nil unless File.exist?(tfvars_file)
|
|
544
|
+
|
|
545
|
+
match = File.read(tfvars_file).match(/^\s*domain\s*=\s*"([^"]+)"/)
|
|
546
|
+
match[1] if match
|
|
547
|
+
end
|
|
508
548
|
end
|
|
509
549
|
end
|
|
510
550
|
end
|
|
@@ -121,8 +121,6 @@ module Belt
|
|
|
121
121
|
destroy_model
|
|
122
122
|
destroy_controller
|
|
123
123
|
remove_routes
|
|
124
|
-
remove_schema
|
|
125
|
-
sync_tables
|
|
126
124
|
destroy_views
|
|
127
125
|
puts "\n✓ Scaffold '#{@singular_name}' destroyed!"
|
|
128
126
|
end
|
|
@@ -130,6 +128,8 @@ module Belt
|
|
|
130
128
|
def destroy_model
|
|
131
129
|
path = "lambda/models/#{@singular_name}.rb"
|
|
132
130
|
remove_file(path)
|
|
131
|
+
remove_schema
|
|
132
|
+
sync_tables
|
|
133
133
|
end
|
|
134
134
|
|
|
135
135
|
def destroy_controller
|
|
@@ -157,14 +157,12 @@ module Belt
|
|
|
157
157
|
@removed << dir
|
|
158
158
|
puts " remove #{dir}/"
|
|
159
159
|
|
|
160
|
-
# Remove frontend.tf
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
main_tf = File.join(env_dir, 'main.tf')
|
|
167
|
-
revert_main_tf_frontend_urls(main_tf)
|
|
160
|
+
# Remove frontend.tf from the module
|
|
161
|
+
module_frontend = 'infrastructure/modules/app/frontend.tf'
|
|
162
|
+
if File.exist?(module_frontend)
|
|
163
|
+
FileUtils.rm_f(module_frontend)
|
|
164
|
+
@removed << module_frontend
|
|
165
|
+
puts " remove #{module_frontend}"
|
|
168
166
|
end
|
|
169
167
|
|
|
170
168
|
puts "\n✓ Frontend removed!"
|
|
@@ -174,24 +172,6 @@ module Belt
|
|
|
174
172
|
end
|
|
175
173
|
end
|
|
176
174
|
|
|
177
|
-
def revert_main_tf_frontend_urls(main_tf)
|
|
178
|
-
return unless File.exist?(main_tf)
|
|
179
|
-
|
|
180
|
-
content = File.read(main_tf)
|
|
181
|
-
return unless content.include?('aws_cloudfront_distribution.frontend.domain_name')
|
|
182
|
-
|
|
183
|
-
# Replace the multi-line concat block back to a simple conditional
|
|
184
|
-
replaced = content.sub(
|
|
185
|
-
/^\s*frontend_urls\s*=\s*concat\(\n(?:.*\n)*?\s*\)\s*\n/,
|
|
186
|
-
" frontend_urls = var.environment == \"prod\" ? [] : [\"http://localhost:3000\"]\n"
|
|
187
|
-
)
|
|
188
|
-
|
|
189
|
-
if replaced != content
|
|
190
|
-
File.write(main_tf, replaced)
|
|
191
|
-
puts " update #{main_tf} (reverted frontend_urls)"
|
|
192
|
-
end
|
|
193
|
-
end
|
|
194
|
-
|
|
195
175
|
def destroy_views
|
|
196
176
|
return unless @resource_name
|
|
197
177
|
|
|
@@ -26,9 +26,10 @@ module Belt
|
|
|
26
26
|
new(env_name).generate
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
-
def initialize(env_name, quiet: false)
|
|
29
|
+
def initialize(env_name, quiet: false, domain: nil)
|
|
30
30
|
@env_name = env_name.downcase.gsub(/[^a-z0-9_-]/, '')
|
|
31
31
|
@app_name = detect_app_name
|
|
32
|
+
@domain = domain
|
|
32
33
|
@quiet = quiet
|
|
33
34
|
end
|
|
34
35
|
|
|
@@ -51,28 +52,19 @@ module Belt
|
|
|
51
52
|
|
|
52
53
|
puts "\n✓ Environment '#{@env_name}' created!"
|
|
53
54
|
|
|
54
|
-
setup_frontend_if_exists
|
|
55
|
-
|
|
56
55
|
unless @quiet
|
|
57
56
|
puts "\nNext steps:"
|
|
58
57
|
puts " cd #{dest_dir}"
|
|
59
58
|
puts ' terraform init'
|
|
60
59
|
puts ' terraform plan'
|
|
61
60
|
puts ' terraform apply'
|
|
61
|
+
puts "\nTo configure a custom domain, set `domain` in #{dest_dir}/terraform.tfvars:"
|
|
62
|
+
puts " domain = \"myapp.com\""
|
|
62
63
|
end
|
|
63
64
|
end
|
|
64
65
|
|
|
65
66
|
private
|
|
66
67
|
|
|
67
|
-
def setup_frontend_if_exists
|
|
68
|
-
return unless Dir.exist?('frontend') && !Dir.empty?('frontend')
|
|
69
|
-
|
|
70
|
-
puts "\n Frontend detected — generating frontend infrastructure..."
|
|
71
|
-
require_relative 'frontend_setup_command'
|
|
72
|
-
FrontendSetupCommand.new(@env_name, quiet: true).run
|
|
73
|
-
puts " create infrastructure/#{@env_name}/frontend.tf"
|
|
74
|
-
end
|
|
75
|
-
|
|
76
68
|
def templates
|
|
77
69
|
{
|
|
78
70
|
'main.tf.erb' => 'main.tf',
|
|
@@ -75,22 +75,20 @@ module Belt
|
|
|
75
75
|
end
|
|
76
76
|
|
|
77
77
|
def setup_frontend_infra_for_existing_environments
|
|
78
|
-
|
|
79
|
-
|
|
78
|
+
module_dir = 'infrastructure/modules/app'
|
|
79
|
+
frontend_tf = File.join(module_dir, 'frontend.tf')
|
|
80
80
|
|
|
81
|
-
|
|
82
|
-
|
|
81
|
+
if File.exist?(frontend_tf)
|
|
82
|
+
puts " skip #{frontend_tf} (already exists)"
|
|
83
|
+
return
|
|
84
|
+
end
|
|
83
85
|
|
|
84
|
-
|
|
85
|
-
frontend_tf = "infrastructure/#{env_name}/frontend.tf"
|
|
86
|
-
if File.exist?(frontend_tf)
|
|
87
|
-
puts " skip #{frontend_tf} (already exists)"
|
|
88
|
-
next
|
|
89
|
-
end
|
|
86
|
+
return unless Dir.exist?(module_dir)
|
|
90
87
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
88
|
+
puts "\n Setting up frontend infrastructure..."
|
|
89
|
+
require_relative 'frontend_setup_command'
|
|
90
|
+
FrontendSetupCommand.new(nil, quiet: true).run
|
|
91
|
+
puts " create #{frontend_tf}"
|
|
94
92
|
end
|
|
95
93
|
|
|
96
94
|
def copy_template(src_dir, dest_dir)
|
|
@@ -4,6 +4,7 @@ require 'shellwords'
|
|
|
4
4
|
require 'open3'
|
|
5
5
|
require_relative 'app_detection'
|
|
6
6
|
require_relative 'env_resolver'
|
|
7
|
+
require_relative 'frontend_env_map'
|
|
7
8
|
|
|
8
9
|
module Belt
|
|
9
10
|
module CLI
|
|
@@ -60,11 +61,18 @@ module Belt
|
|
|
60
61
|
run!(*install_cmd, chdir: 'frontend')
|
|
61
62
|
|
|
62
63
|
puts '🏗️ Building frontend...'
|
|
63
|
-
|
|
64
|
-
env
|
|
64
|
+
env = frontend_build_env
|
|
65
|
+
if env.any?
|
|
66
|
+
puts " Injecting env: #{env.keys.sort.join(', ')}"
|
|
67
|
+
end
|
|
65
68
|
run!(env, 'npm', 'run', 'build', chdir: 'frontend')
|
|
66
69
|
end
|
|
67
70
|
|
|
71
|
+
# Resolve build env from frontend/env.yml map (or default VITE_API_URL ← api_url).
|
|
72
|
+
def frontend_build_env
|
|
73
|
+
FrontendEnvMap.new(@env, env_dir: @env_dir).process_env
|
|
74
|
+
end
|
|
75
|
+
|
|
68
76
|
def sync_to_s3
|
|
69
77
|
bucket = fetch_bucket_name
|
|
70
78
|
abort "Error: Could not determine S3 bucket. Run `belt apply #{@env}` first." unless bucket
|
|
@@ -94,24 +102,24 @@ module Belt
|
|
|
94
102
|
puts '✅ CloudFront cache invalidated'
|
|
95
103
|
end
|
|
96
104
|
|
|
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
105
|
def fetch_bucket_name
|
|
103
|
-
|
|
104
|
-
status.success? && !output.strip.empty? ? output.strip : nil
|
|
106
|
+
fetch_tf_output('frontend_bucket_name')
|
|
105
107
|
end
|
|
106
108
|
|
|
107
109
|
def fetch_distribution_id
|
|
108
|
-
|
|
109
|
-
status.success? && !output.strip.empty? ? output.strip : nil
|
|
110
|
+
fetch_tf_output('frontend_distribution_id')
|
|
110
111
|
end
|
|
111
112
|
|
|
112
113
|
def fetch_frontend_url
|
|
113
|
-
|
|
114
|
-
|
|
114
|
+
fetch_tf_output('frontend_url')
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def fetch_tf_output(name)
|
|
118
|
+
output, status = Open3.capture2('terraform', 'output', '-raw', name, chdir: @env_dir)
|
|
119
|
+
return nil unless status.success?
|
|
120
|
+
|
|
121
|
+
value = output.strip
|
|
122
|
+
value.empty? || value == 'null' ? nil : value
|
|
115
123
|
end
|
|
116
124
|
|
|
117
125
|
def run!(*args, **)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'app_detection'
|
|
4
|
+
require_relative 'env_resolver'
|
|
5
|
+
require_relative 'frontend_env_map'
|
|
6
|
+
|
|
7
|
+
module Belt
|
|
8
|
+
module CLI
|
|
9
|
+
# belt frontend env <environment>
|
|
10
|
+
#
|
|
11
|
+
# Smart-merges terraform outputs into frontend/.env using the declarative map
|
|
12
|
+
# (frontend/env.yml or .belt/frontend_env.yml). Only mapped keys are written;
|
|
13
|
+
# custom local keys are preserved.
|
|
14
|
+
class FrontendEnvCommand
|
|
15
|
+
include AppDetection
|
|
16
|
+
|
|
17
|
+
def self.run(args)
|
|
18
|
+
subcommand = args.shift
|
|
19
|
+
|
|
20
|
+
case subcommand
|
|
21
|
+
when 'env'
|
|
22
|
+
run_env(args)
|
|
23
|
+
when nil, '-h', '--help', 'help'
|
|
24
|
+
puts usage
|
|
25
|
+
exit(subcommand.nil? ? 1 : 0)
|
|
26
|
+
else
|
|
27
|
+
puts "Unknown frontend subcommand: #{subcommand}\n\n#{usage}"
|
|
28
|
+
exit 1
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.run_env(args)
|
|
33
|
+
env = EnvResolver.resolve(args)
|
|
34
|
+
|
|
35
|
+
if env.nil?
|
|
36
|
+
puts 'Usage: belt frontend env <environment>'
|
|
37
|
+
puts "\nWrites frontend/.env from terraform outputs using the env map."
|
|
38
|
+
puts 'You can also set BELT_ENV to skip the environment argument.'
|
|
39
|
+
puts "\nMap file (optional): frontend/env.yml or .belt/frontend_env.yml"
|
|
40
|
+
puts 'Default without map: VITE_API_URL ← api_url'
|
|
41
|
+
puts "\nExamples:"
|
|
42
|
+
puts ' belt frontend env dev'
|
|
43
|
+
puts ' BELT_ENV=dev belt frontend env'
|
|
44
|
+
exit 1
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
new(env).run
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.usage
|
|
51
|
+
<<~USAGE
|
|
52
|
+
Frontend helpers.
|
|
53
|
+
|
|
54
|
+
Usage:
|
|
55
|
+
belt frontend env <environment> Write frontend/.env from terraform outputs
|
|
56
|
+
belt frontend --help
|
|
57
|
+
|
|
58
|
+
Env map (optional):
|
|
59
|
+
frontend/env.yml
|
|
60
|
+
.belt/frontend_env.yml
|
|
61
|
+
|
|
62
|
+
Example map:
|
|
63
|
+
VITE_API_URL: api_url
|
|
64
|
+
VITE_COGNITO_USER_POOL_ID: cognito_user_pool_id
|
|
65
|
+
VITE_COGNITO_CLIENT_ID: cognito_user_pool_client_id
|
|
66
|
+
VITE_AWS_REGION: cognito_region
|
|
67
|
+
|
|
68
|
+
Without a map, belt injects only VITE_API_URL from api_url.
|
|
69
|
+
|
|
70
|
+
Smart merge: only keys listed in the map are updated. Other .env keys
|
|
71
|
+
(local secrets, feature flags) are left alone. Missing terraform outputs
|
|
72
|
+
warn and do not overwrite existing values.
|
|
73
|
+
USAGE
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def initialize(env)
|
|
77
|
+
@env = env
|
|
78
|
+
@app_name = detect_app_name
|
|
79
|
+
@env_dir = "infrastructure/#{@env}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def run
|
|
83
|
+
unless Dir.exist?('frontend')
|
|
84
|
+
abort 'Error: No frontend/ directory found. Run `belt generate frontend react` first.'
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
unless Dir.exist?(@env_dir)
|
|
88
|
+
abort "Error: infrastructure/#{@env} not found. Run `belt generate environment #{@env}` first."
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
map = FrontendEnvMap.new(@env, env_dir: @env_dir)
|
|
92
|
+
|
|
93
|
+
if map.using_default_map?
|
|
94
|
+
puts '📋 No frontend/env.yml found — using default map (VITE_API_URL ← api_url)'
|
|
95
|
+
else
|
|
96
|
+
puts "📋 Loading env map from #{map.map_path}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
result = map.write_dotenv!
|
|
100
|
+
updated = result[:updated]
|
|
101
|
+
|
|
102
|
+
if updated.empty?
|
|
103
|
+
puts "⚠️ No values written to #{result[:path]} (terraform outputs missing?)"
|
|
104
|
+
else
|
|
105
|
+
puts "✅ Updated #{result[:path]} (#{updated.join(', ')})"
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'open3'
|
|
5
|
+
require 'yaml'
|
|
6
|
+
|
|
7
|
+
module Belt
|
|
8
|
+
module CLI
|
|
9
|
+
# Resolves frontend env vars from a declarative map (env name → terraform output).
|
|
10
|
+
#
|
|
11
|
+
# Map file (optional), first match wins:
|
|
12
|
+
# frontend/env.yml
|
|
13
|
+
# .belt/frontend_env.yml
|
|
14
|
+
#
|
|
15
|
+
# Example:
|
|
16
|
+
# VITE_API_URL: api_url
|
|
17
|
+
# VITE_COGNITO_USER_POOL_ID: cognito_user_pool_id
|
|
18
|
+
# REACT_APP_API_URL: api_url
|
|
19
|
+
#
|
|
20
|
+
# No map → default { "VITE_API_URL" => "api_url" } (backwards compatible).
|
|
21
|
+
class FrontendEnvMap
|
|
22
|
+
MAP_CANDIDATES = [
|
|
23
|
+
File.join('frontend', 'env.yml'),
|
|
24
|
+
File.join('.belt', 'frontend_env.yml')
|
|
25
|
+
].freeze
|
|
26
|
+
|
|
27
|
+
DEFAULT_MAP = { 'VITE_API_URL' => 'api_url' }.freeze
|
|
28
|
+
|
|
29
|
+
DOTENV_PATH = File.join('frontend', '.env')
|
|
30
|
+
|
|
31
|
+
attr_reader :env_name, :env_dir, :map_path, :map
|
|
32
|
+
|
|
33
|
+
def initialize(env_name, env_dir: nil, map_path: nil)
|
|
34
|
+
@env_name = env_name
|
|
35
|
+
@env_dir = env_dir || File.join('infrastructure', env_name)
|
|
36
|
+
@map_path = map_path || self.class.find_map_path
|
|
37
|
+
@map = load_map
|
|
38
|
+
@tf_cache = {}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.find_map_path
|
|
42
|
+
MAP_CANDIDATES.find { |path| File.file?(path) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Hash of env_var => value suitable for process env (npm run build / vite).
|
|
46
|
+
# Skips keys whose terraform output is missing.
|
|
47
|
+
def process_env
|
|
48
|
+
resolved = {}
|
|
49
|
+
each_resolved do |env_key, value, _tf_output|
|
|
50
|
+
resolved[env_key] = value if value
|
|
51
|
+
end
|
|
52
|
+
resolved
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Smart-merge mapped keys into frontend/.env.
|
|
56
|
+
# - Only overwrites keys present in the map
|
|
57
|
+
# - Preserves unknown keys, comments, and blank lines
|
|
58
|
+
# - Missing TF output → warn, do not clobber an existing value
|
|
59
|
+
def write_dotenv!(path: DOTENV_PATH)
|
|
60
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
61
|
+
|
|
62
|
+
existing_lines = File.file?(path) ? File.readlines(path, chomp: true) : []
|
|
63
|
+
updates = {}
|
|
64
|
+
missing = []
|
|
65
|
+
|
|
66
|
+
each_resolved do |env_key, value, tf_output|
|
|
67
|
+
if value
|
|
68
|
+
updates[env_key] = value
|
|
69
|
+
else
|
|
70
|
+
missing << [env_key, tf_output]
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
missing.each do |env_key, tf_output|
|
|
75
|
+
puts "⚠️ terraform output '#{tf_output}' missing — leaving #{env_key} unchanged in #{path}"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
written = {}
|
|
79
|
+
new_lines = existing_lines.map do |line|
|
|
80
|
+
key = dotenv_key(line)
|
|
81
|
+
if key && updates.key?(key)
|
|
82
|
+
written[key] = true
|
|
83
|
+
format_dotenv_line(key, updates[key])
|
|
84
|
+
else
|
|
85
|
+
line
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
updates.each do |key, value|
|
|
90
|
+
next if written[key]
|
|
91
|
+
|
|
92
|
+
new_lines << format_dotenv_line(key, value)
|
|
93
|
+
written[key] = true
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
content = "#{new_lines.join("\n")}\n"
|
|
97
|
+
File.write(path, content)
|
|
98
|
+
|
|
99
|
+
{
|
|
100
|
+
path: path,
|
|
101
|
+
updated: written.keys.sort,
|
|
102
|
+
missing: missing.map(&:first)
|
|
103
|
+
}
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def using_default_map?
|
|
107
|
+
@map_path.nil?
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
def load_map
|
|
113
|
+
return DEFAULT_MAP.dup if @map_path.nil?
|
|
114
|
+
|
|
115
|
+
raw = YAML.safe_load(File.read(@map_path), aliases: false)
|
|
116
|
+
unless raw.is_a?(Hash) && raw.any?
|
|
117
|
+
abort "Error: frontend env map #{@map_path} must be a non-empty YAML mapping " \
|
|
118
|
+
'(e.g. VITE_API_URL: api_url).'
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
raw.each_with_object({}) do |(key, value), hash|
|
|
122
|
+
k = key.to_s.strip
|
|
123
|
+
v = value.to_s.strip
|
|
124
|
+
next if k.empty? || v.empty?
|
|
125
|
+
|
|
126
|
+
hash[k] = v
|
|
127
|
+
end.tap do |parsed|
|
|
128
|
+
abort "Error: frontend env map #{@map_path} has no valid entries." if parsed.empty?
|
|
129
|
+
end
|
|
130
|
+
rescue Psych::SyntaxError => e
|
|
131
|
+
abort "Error: invalid YAML in #{@map_path}: #{e.message}"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def each_resolved
|
|
135
|
+
map.each do |env_key, tf_output|
|
|
136
|
+
yield env_key, fetch_tf_output(tf_output), tf_output
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def fetch_tf_output(name)
|
|
141
|
+
return @tf_cache[name] if @tf_cache.key?(name)
|
|
142
|
+
|
|
143
|
+
@tf_cache[name] = read_tf_output(name)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def read_tf_output(name)
|
|
147
|
+
return nil unless Dir.exist?(@env_dir)
|
|
148
|
+
|
|
149
|
+
output, status = Open3.capture2(
|
|
150
|
+
'terraform', 'output', '-raw', name,
|
|
151
|
+
chdir: @env_dir,
|
|
152
|
+
err: File::NULL
|
|
153
|
+
)
|
|
154
|
+
return nil unless status.success?
|
|
155
|
+
|
|
156
|
+
value = output.strip
|
|
157
|
+
return nil if value.empty? || value == 'null'
|
|
158
|
+
|
|
159
|
+
value
|
|
160
|
+
rescue Errno::ENOENT
|
|
161
|
+
nil
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Match KEY=value lines; ignore comments and export prefixes.
|
|
165
|
+
def dotenv_key(line)
|
|
166
|
+
return nil if line.strip.empty? || line.lstrip.start_with?('#')
|
|
167
|
+
|
|
168
|
+
stripped = line.strip
|
|
169
|
+
stripped = stripped.sub(/\Aexport\s+/, '')
|
|
170
|
+
return nil unless stripped =~ /\A([A-Za-z_][A-Za-z0-9_]*)=/
|
|
171
|
+
|
|
172
|
+
Regexp.last_match(1)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def format_dotenv_line(key, value)
|
|
176
|
+
"#{key}=#{escape_dotenv_value(value)}"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def escape_dotenv_value(value)
|
|
180
|
+
str = value.to_s
|
|
181
|
+
return str if str.empty?
|
|
182
|
+
return str unless str.match?(/[\s\#"'\\$`]/)
|
|
183
|
+
|
|
184
|
+
%("#{str.gsub('\\', '\\\\').gsub('"', '\\"')}")
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|