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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ebbefe3f44abda51ac10b219cda18678ca834d06d8eab39e31beb3e48e99b25a
4
- data.tar.gz: 3233b91c7297982ef7f5037331ef003c3637ebc6dccf4321b4a5f758ed57f1d9
3
+ metadata.gz: c48b3087bb87bb86b2111cc243cf57d889e8f3d94fb25f1b023e5ba3dabf4973
4
+ data.tar.gz: 53b43e092412aaab0a0fae0e531d7d7ffb07c7a69e19c3e7e7480cf19753184f
5
5
  SHA512:
6
- metadata.gz: af38bf5330f69577f37ab34a0bd7510b5d84aa14969efd8975ac6875ea85450aaa741beeb71bf3f51f627af11c0c5445806081156706cdfd4901fba4301e33ed
7
- data.tar.gz: 2eade1680ebfe76e78ab38e38d273565cf34eb1f1789427d358706ac2c4c128a5f86448a5eb2a146161be8c912bf53f9229fceb0904719c9df5ef3444dc3481e
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 and revert frontend_urls in main.tf for each environment
161
- Dir.glob('infrastructure/*/frontend.tf').each do |frontend_tf|
162
- FileUtils.rm_f(frontend_tf)
163
- puts " remove #{frontend_tf}"
164
-
165
- env_dir = File.dirname(frontend_tf)
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
- environments = detect_environments
79
- return if environments.empty?
78
+ module_dir = 'infrastructure/modules/app'
79
+ frontend_tf = File.join(module_dir, 'frontend.tf')
80
80
 
81
- puts "\n Setting up frontend infrastructure for existing environments..."
82
- require_relative 'frontend_setup_command'
81
+ if File.exist?(frontend_tf)
82
+ puts " skip #{frontend_tf} (already exists)"
83
+ return
84
+ end
83
85
 
84
- environments.each do |env_name|
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
- FrontendSetupCommand.new(env_name, quiet: true).run
92
- puts " create #{frontend_tf}"
93
- end
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
- api_url = fetch_api_url
64
- env = api_url ? { 'VITE_API_URL' => api_url } : {}
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
- output, status = Open3.capture2('terraform', 'output', '-raw', 'frontend_bucket_name', chdir: @env_dir)
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
- output, status = Open3.capture2('terraform', 'output', '-raw', 'frontend_distribution_id', chdir: @env_dir)
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
- output, status = Open3.capture2('terraform', 'output', '-raw', 'frontend_url', chdir: @env_dir)
114
- status.success? && !output.strip.empty? ? output.strip : nil
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