belt 0.1.9 → 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: c3c75f106f7a0f2ebf672294ee30e96f96745fb3ecbd142307c08b6b006933c8
4
- data.tar.gz: 470cefb769cfab7dbaf80e6f950eb62cf87fb5a6b297f3661cf147c26d494501
3
+ metadata.gz: c48b3087bb87bb86b2111cc243cf57d889e8f3d94fb25f1b023e5ba3dabf4973
4
+ data.tar.gz: 53b43e092412aaab0a0fae0e531d7d7ffb07c7a69e19c3e7e7480cf19753184f
5
5
  SHA512:
6
- metadata.gz: e5c5ed3dfa729483fd54e28197c9bd2f47f3581bea5f0892a4ac44c02f55253809f4fe1c12167ca55a040bdebc97e545b462f08d2f42c23dd1304a4a9dfd7768
7
- data.tar.gz: be36355b315e467f411079880a4b3e14e94b56567cbf1892a95fff3f2b3c1c9a8bfe91e2e8618b405b1b06045ee1c06e999c12a6c281b9c490b8e8dc43d8f999
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
@@ -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
@@ -3,6 +3,7 @@
3
3
  require 'base64'
4
4
  require 'json'
5
5
  require_relative 'app_detection'
6
+ require_relative 'frontend_env_map'
6
7
  require_relative 'terraform_command'
7
8
 
8
9
  module Belt
@@ -50,13 +51,14 @@ module Belt
50
51
 
51
52
  Behavior:
52
53
  • If frontend/ exists → runs the frontend dev server (npm run dev)
53
- Automatically sets VITE_API_URL from terraform outputs if deployed.
54
+ Injects env from frontend/env.yml (or default VITE_API_URL) using
55
+ terraform outputs when available.
54
56
  • If no frontend → serves the welcome page via a local HTTP server
55
57
  After deploy, shows live API URL and deployment status.
56
58
 
57
59
  Note: The backend is serverless (AWS Lambda). Use `belt deploy` to deploy
58
- your backend to AWS. Local frontend development automatically points to
59
- your deployed API via VITE_API_URL.
60
+ your backend to AWS. Local frontend development reads the env map (or
61
+ frontend/.env via `belt frontend env <env>`).
60
62
 
61
63
  Examples:
62
64
  belt server # Start on port #{DEFAULT_PORT}
@@ -84,17 +86,22 @@ module Belt
84
86
 
85
87
  def run_frontend_dev_server
86
88
  puts "🚀 Starting frontend dev server on port #{@port}..."
87
- if @api_url
88
- puts " Backend API: #{@api_url}"
89
+ build_env = frontend_process_env
90
+ api_url = build_env['VITE_API_URL'] || build_env['REACT_APP_API_URL'] ||
91
+ build_env['NEXT_PUBLIC_API_URL'] || @api_url
92
+ if api_url
93
+ puts " Backend API: #{api_url}"
89
94
  else
90
95
  puts ' Backend is serverless — deploy with `belt deploy` to set up AWS resources.'
91
96
  end
97
+ if build_env.any?
98
+ puts " Env: #{build_env.keys.sort.join(', ')}"
99
+ end
92
100
  puts ''
93
101
 
94
102
  open_browser_later if @open_browser
95
103
 
96
- env = { 'PORT' => @port.to_s }
97
- env['VITE_API_URL'] = @api_url if @api_url
104
+ env = { 'PORT' => @port.to_s }.merge(build_env)
98
105
 
99
106
  # Prefer the dev script with the port flag for Vite-based setups
100
107
  Dir.chdir('frontend') do
@@ -102,6 +109,17 @@ module Belt
102
109
  end
103
110
  end
104
111
 
112
+ # Process env from the declarative map (or default VITE_API_URL), if an env is available.
113
+ def frontend_process_env
114
+ env_name = @deploy_env || ENV.fetch('BELT_ENV', nil) || TerraformCommand.list_environments.first
115
+ return {} unless env_name
116
+
117
+ FrontendEnvMap.new(env_name).process_env
118
+ rescue StandardError
119
+ # Fall back to legacy api_url detection if map resolution fails
120
+ @api_url ? { 'VITE_API_URL' => @api_url } : {}
121
+ end
122
+
105
123
  def run_welcome_server
106
124
  if @api_url
107
125
  puts "🚀 Serving Belt welcome page on http://localhost:#{@port}"
data/lib/belt/cli.rb CHANGED
@@ -9,6 +9,7 @@ require_relative 'cli/destroy_command'
9
9
  require_relative 'cli/frontend_command'
10
10
  require_relative 'cli/frontend_setup_command'
11
11
  require_relative 'cli/frontend_deploy_command'
12
+ require_relative 'cli/frontend_env_command'
12
13
  require_relative 'cli/views_command'
13
14
  require_relative 'cli/setup_command'
14
15
  require_relative 'cli/terraform_command'
@@ -29,10 +30,12 @@ module Belt
29
30
  %w[tasks --tasks -T] => Belt::CLI::TasksCommand,
30
31
  'setup' => Belt::CLI::SetupCommand,
31
32
  'deploy' => Belt::CLI::DeployCommand,
33
+ 'frontend' => Belt::CLI::FrontendEnvCommand,
32
34
  %w[server s] => Belt::CLI::ServerCommand,
33
35
  %w[version --version -v] => ->(_args) { puts "Belt #{Belt::VERSION}" }
34
36
  }.freeze
35
37
 
38
+
36
39
  COMMANDS = COMMANDS_DEFINITION.each_with_object({}) do |(keys, handler), hash|
37
40
  Array(keys).each { |key| hash[key] = handler }
38
41
  end.freeze
@@ -99,7 +102,9 @@ module Belt
99
102
  s Alias for server
100
103
  deploy [environment] Deploy to AWS (init → plan → apply)
101
104
  deploy frontend <env> Build and deploy frontend to AWS
105
+ frontend env <env> Write frontend/.env from terraform outputs
102
106
  routes [-g PATTERN] [-f json] Show route definitions
107
+
103
108
  console Start an interactive console (IRB)
104
109
  c Alias for console
105
110
  tasks [-g PATTERN] [-a] List available rake tasks
@@ -133,6 +138,7 @@ module Belt
133
138
  belt deploy # Deploy dev to AWS
134
139
  belt deploy prod --auto # Deploy prod without confirmation
135
140
  belt deploy frontend wups
141
+ belt frontend env wups # Smart-merge TF outputs into frontend/.env
136
142
  belt setup frontend wups
137
143
  belt apply wups
138
144
  belt tasks # list all rake tasks
data/lib/belt/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Belt
4
- VERSION = '0.1.9'
4
+ VERSION = '0.1.10'
5
5
  end
@@ -0,0 +1,13 @@
1
+ # Declarative frontend env map for belt.
2
+ # Left: env var your build tool reads. Right: terraform output name.
3
+ # Copy to frontend/env.yml (or .belt/frontend_env.yml) and adjust.
4
+ #
5
+ # belt deploy frontend <env> → injects these into npm run build
6
+ # belt frontend env <env> → smart-merges into frontend/.env for local dev
7
+ #
8
+ # Without this file, belt only sets VITE_API_URL from api_url.
9
+
10
+ VITE_API_URL: api_url
11
+ # VITE_COGNITO_USER_POOL_ID: cognito_user_pool_id
12
+ # VITE_COGNITO_CLIENT_ID: cognito_user_pool_client_id
13
+ # VITE_AWS_REGION: cognito_region
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: belt
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.9
4
+ version: 0.1.10
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stowzilla
@@ -78,6 +78,8 @@ files:
78
78
  - lib/belt/cli/environment_command.rb
79
79
  - lib/belt/cli/frontend_command.rb
80
80
  - lib/belt/cli/frontend_deploy_command.rb
81
+ - lib/belt/cli/frontend_env_command.rb
82
+ - lib/belt/cli/frontend_env_map.rb
81
83
  - lib/belt/cli/frontend_setup_command.rb
82
84
  - lib/belt/cli/generate_command.rb
83
85
  - lib/belt/cli/new_command.rb
@@ -110,6 +112,7 @@ files:
110
112
  - lib/templates/environment/outputs.tf.erb
111
113
  - lib/templates/environment/terraform.tfvars.erb
112
114
  - lib/templates/environment/variables.tf.erb
115
+ - lib/templates/frontend/react/env.yml.example
113
116
  - lib/templates/frontend/react/index.html.erb
114
117
  - lib/templates/frontend/react/package.json.erb
115
118
  - lib/templates/frontend/react/src/App.jsx