belt 0.3.4 → 0.3.6

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.

Potentially problematic release.


This version of belt might be problematic. Click here for more details.

@@ -5,6 +5,7 @@ require 'open3'
5
5
  require_relative 'app_detection'
6
6
  require_relative 'env_resolver'
7
7
  require_relative 'frontend_env_map'
8
+ require_relative 'frontend_registry'
8
9
 
9
10
  module Belt
10
11
  module CLI
@@ -12,12 +13,16 @@ module Belt
12
13
  include AppDetection
13
14
 
14
15
  def self.run(args)
16
+ frontend_name = FrontendRegistry.extract_flag!(args, '--frontend')
15
17
  env = EnvResolver.resolve(args)
16
18
 
17
19
  if env.nil?
18
- puts 'Usage: belt deploy frontend <environment>'
19
- puts "\nBuilds the frontend app and deploys to S3 + invalidates CloudFront."
20
+ puts 'Usage: belt deploy frontend <environment> [--frontend NAME]'
21
+ puts "\nBuilds frontend app(s) and deploys to S3 + invalidates CloudFront."
20
22
  puts 'You can also set BELT_ENV to skip the environment argument.'
23
+ puts "\nWith multiple frontends (config/frontends.yml):"
24
+ puts ' belt deploy frontend wups # deploy all'
25
+ puts ' belt deploy frontend wups --frontend ops # deploy one'
21
26
  puts "\nExamples:"
22
27
  puts ' belt deploy frontend wups'
23
28
  puts ' belt deploy frontend dev01'
@@ -25,65 +30,101 @@ module Belt
25
30
  exit 1
26
31
  end
27
32
 
28
- new(env).run
33
+ leftover = args.reject { |a| a.start_with?('-') }
34
+ frontend_name ||= leftover.shift
35
+
36
+ if frontend_name
37
+ frontend = FrontendRegistry.new.resolve!(frontend_name)
38
+ new(env, frontend: frontend).run
39
+ else
40
+ deploy_all(env)
41
+ end
42
+ end
43
+
44
+ def self.deploy_all(env)
45
+ frontends = FrontendRegistry.new.existing
46
+ abort FrontendRegistry.new.empty_message if frontends.empty?
47
+
48
+ frontends.each_with_index do |frontend, index|
49
+ puts if index.positive?
50
+ new(env, frontend: frontend).run
51
+ end
29
52
  end
30
53
 
31
- def initialize(env)
54
+ def initialize(env, frontend: nil)
32
55
  @env = env
33
56
  @app_name = detect_app_name
34
57
  @env_dir = "infrastructure/#{@env}"
58
+ @frontend = frontend || FrontendRegistry.new.resolve!
35
59
  end
36
60
 
37
61
  def run
38
62
  validate!
63
+ puts "━━━ #{@frontend.label} (#{@frontend.path}/) ━━━"
39
64
  build_frontend
40
65
  sync_to_s3
41
66
  invalidate_cloudfront
42
67
  url = fetch_frontend_url
43
- puts "\n✅ Frontend deployed to #{@env}!"
68
+ puts "\n✅ #{@frontend.label.capitalize} deployed to #{@env}!"
44
69
  puts " #{url}" if url
45
70
  end
46
71
 
47
72
  private
48
73
 
49
74
  def validate!
50
- unless Dir.exist?('frontend')
51
- abort 'Error: No frontend/ directory found. Run `belt generate frontend react` first.'
75
+ unless Dir.exist?(@frontend.path)
76
+ abort "Error: No #{@frontend.path}/ directory found. " \
77
+ 'Run `belt generate frontend react --name ' \
78
+ "#{@frontend.name} --path #{@frontend.path}` first."
52
79
  end
53
- return if File.exist?('frontend/package.json')
80
+ return if File.exist?(@frontend.package_json)
54
81
 
55
- abort 'Error: frontend/package.json not found.'
82
+ abort "Error: #{@frontend.package_json} not found."
56
83
  end
57
84
 
58
85
  def build_frontend
59
86
  puts '📦 Installing dependencies...'
60
- install_cmd = File.exist?('frontend/package-lock.json') ? %w[npm ci] : %w[npm install]
61
- run!(*install_cmd, chdir: 'frontend')
87
+ install_cmd = File.exist?(File.join(@frontend.path, 'package-lock.json')) ? %w[npm ci] : %w[npm install]
88
+ run!(*install_cmd, chdir: @frontend.path)
62
89
 
63
- puts '🏗️ Building frontend...'
90
+ puts "🏗️ Building #{@frontend.label}..."
64
91
  env = frontend_build_env
65
92
  puts " Injecting env: #{env.keys.sort.join(', ')}" if env.any?
66
- run!(env, 'npm', 'run', 'build', chdir: 'frontend')
93
+ run!(env, 'npm', 'run', 'build', chdir: @frontend.path)
67
94
  end
68
95
 
69
- # Resolve build env from frontend/env.yml map (or default VITE_API_URL ← api_url).
70
96
  def frontend_build_env
71
- FrontendEnvMap.new(@env, env_dir: @env_dir).process_env
97
+ FrontendEnvMap.new(
98
+ @env,
99
+ env_dir: @env_dir,
100
+ frontend_path: @frontend.path
101
+ ).process_env
72
102
  end
73
103
 
74
104
  def sync_to_s3
75
105
  bucket = fetch_bucket_name
76
106
  abort "Error: Could not determine S3 bucket. Run `belt apply #{@env}` first." unless bucket
77
107
 
108
+ dist = @frontend.dist_dir
109
+ unless Dir.exist?(dist)
110
+ abort "Error: Build output not found at #{dist}/. " \
111
+ 'Set `dist:` in config/frontends.yml if the app uses a non-default outDir.'
112
+ end
113
+
78
114
  puts "☁️ Deploying to S3... (#{bucket})"
79
115
 
116
+ dist_prefix = dist.end_with?('/') ? dist : "#{dist}/"
117
+
80
118
  # Hashed assets get immutable cache headers
81
- run!('aws', 's3', 'sync', 'frontend/dist/', "s3://#{bucket}", '--delete',
119
+ run!('aws', 's3', 'sync', dist_prefix, "s3://#{bucket}", '--delete',
82
120
  '--size-only', '--cache-control', 'public, max-age=31536000, immutable',
83
121
  '--exclude', 'index.html')
84
122
 
123
+ index = File.join(dist, 'index.html')
124
+ abort "Error: #{index} not found after build." unless File.exist?(index)
125
+
85
126
  # index.html always revalidates
86
- run!('aws', 's3', 'cp', 'frontend/dist/index.html', "s3://#{bucket}/index.html",
127
+ run!('aws', 's3', 'cp', index, "s3://#{bucket}/index.html",
87
128
  '--cache-control', 'no-cache')
88
129
  end
89
130
 
@@ -101,23 +142,71 @@ module Belt
101
142
  end
102
143
 
103
144
  def fetch_bucket_name
104
- fetch_tf_output('frontend_bucket_name')
145
+ fetch_tf_output(@frontend.bucket_output)
105
146
  end
106
147
 
107
148
  def fetch_distribution_id
108
- fetch_tf_output('frontend_distribution_id')
149
+ if probe_distribution_output?
150
+ id = fetch_tf_output(@frontend.distribution_output)
151
+ return id if id
152
+ end
153
+
154
+ domain = fetch_tf_output(@frontend.cloudfront_domain_output) if @frontend.cloudfront_domain_output
155
+ domain ||= domain_from_url(fetch_frontend_url)
156
+ lookup_distribution_id(domain)
157
+ end
158
+
159
+ # Belt-generated terraform exports `{name}_frontend_distribution_id`.
160
+ # Stowzilla-style configs only export a CloudFront domain — skip the
161
+ # inferred ID output so terraform doesn't print "output not found".
162
+ def probe_distribution_output?
163
+ @frontend.distribution_output_explicit? || @frontend.cloudfront_domain_output.to_s.empty?
109
164
  end
110
165
 
111
166
  def fetch_frontend_url
112
- fetch_tf_output('frontend_url')
167
+ fetch_tf_output(@frontend.url_output)
168
+ end
169
+
170
+ def domain_from_url(url)
171
+ return nil if url.nil? || url.empty?
172
+
173
+ url.sub(%r{\Ahttps?://}i, '').split('/').first
174
+ end
175
+
176
+ # Stowzilla-style fallback: terraform may export a CloudFront domain
177
+ # instead of a distribution ID.
178
+ def lookup_distribution_id(domain)
179
+ return nil if domain.nil? || domain.empty?
180
+
181
+ query = "DistributionList.Items[?DomainName=='#{domain}'].Id"
182
+ output, status = Open3.capture2(
183
+ 'aws', 'cloudfront', 'list-distributions',
184
+ '--query', query, '--output', 'text',
185
+ err: File::NULL
186
+ )
187
+ return nil unless status.success?
188
+
189
+ value = output.strip
190
+ value.empty? || value == 'None' ? nil : value.split(/\s+/).first
191
+ rescue Errno::ENOENT
192
+ nil
113
193
  end
114
194
 
115
195
  def fetch_tf_output(name)
116
- output, status = Open3.capture2('terraform', 'output', '-raw', name, chdir: @env_dir)
196
+ return nil if name.nil? || name.to_s.empty?
197
+ return nil unless Dir.exist?(@env_dir)
198
+
199
+ output, status = Open3.capture2(
200
+ 'terraform', 'output', '-raw', name,
201
+ chdir: @env_dir,
202
+ err: File::NULL
203
+ )
117
204
  return nil unless status.success?
118
205
 
119
206
  value = output.strip
120
207
  value.empty? || value == 'null' ? nil : value
208
+ rescue Errno::ENOENT
209
+ nil
121
210
  end
122
211
 
123
212
  def run!(*args, **)
@@ -3,14 +3,15 @@
3
3
  require_relative 'app_detection'
4
4
  require_relative 'env_resolver'
5
5
  require_relative 'frontend_env_map'
6
+ require_relative 'frontend_registry'
6
7
 
7
8
  module Belt
8
9
  module CLI
9
- # belt frontend env <environment>
10
+ # belt frontend env <environment> [--frontend NAME]
11
+ # belt frontend list
10
12
  #
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.
13
+ # Smart-merges terraform outputs into <frontend>/.env using the declarative map
14
+ # (<frontend>/env.yml or .belt/frontend_env.yml for the default frontend).
14
15
  class FrontendEnvCommand
15
16
  include AppDetection
16
17
 
@@ -20,6 +21,8 @@ module Belt
20
21
  case subcommand
21
22
  when 'env'
22
23
  run_env(args)
24
+ when 'list', 'ls'
25
+ run_list
23
26
  when nil, '-h', '--help', 'help'
24
27
  puts usage
25
28
  exit(subcommand.nil? ? 1 : 0)
@@ -29,22 +32,51 @@ module Belt
29
32
  end
30
33
  end
31
34
 
35
+ def self.run_list
36
+ registry = FrontendRegistry.new
37
+ if registry.empty?
38
+ puts 'No frontends configured.'
39
+ puts 'Run `belt generate frontend react` or add config/frontends.yml.'
40
+ return
41
+ end
42
+
43
+ puts 'Frontends:'
44
+ registry.all.each do |fe|
45
+ marker = fe.default? ? ' (default)' : ''
46
+ status = fe.exists? ? '' : ' missing'
47
+ puts " #{fe.name.ljust(16)} #{fe.path}/#{marker}#{status}"
48
+ end
49
+ end
50
+
32
51
  def self.run_env(args)
52
+ frontend_name = FrontendRegistry.extract_flag!(args, '--frontend')
33
53
  env = EnvResolver.resolve(args)
34
54
 
35
55
  if env.nil?
36
- puts 'Usage: belt frontend env <environment>'
37
- puts "\nWrites frontend/.env from terraform outputs using the env map."
56
+ puts 'Usage: belt frontend env <environment> [--frontend NAME]'
57
+ puts "\nWrites <frontend>/.env from terraform outputs using the env map."
38
58
  puts 'You can also set BELT_ENV to skip the environment argument.'
39
- puts "\nMap file (optional): frontend/env.{yml,yaml} or .belt/frontend_env.{yml,yaml}"
59
+ puts "\nMap file (optional): <frontend>/env.{yml,yaml} or .belt/frontend_env.{yml,yaml}"
40
60
  puts 'Default without map: VITE_API_URL ← api_url'
41
61
  puts "\nExamples:"
42
62
  puts ' belt frontend env dev'
63
+ puts ' belt frontend env dev --frontend ops'
43
64
  puts ' BELT_ENV=dev belt frontend env'
44
65
  exit 1
45
66
  end
46
67
 
47
- new(env).run
68
+ leftover = args.reject { |a| a.start_with?('-') }
69
+ frontend_name ||= leftover.shift
70
+
71
+ if frontend_name
72
+ frontend = FrontendRegistry.new.resolve!(frontend_name)
73
+ new(env, frontend: frontend).run
74
+ else
75
+ frontends = FrontendRegistry.new.all
76
+ abort FrontendRegistry.new.empty_message if frontends.empty?
77
+
78
+ frontends.each { |fe| new(env, frontend: fe).run }
79
+ end
48
80
  end
49
81
 
50
82
  def self.usage
@@ -52,12 +84,15 @@ module Belt
52
84
  Frontend helpers.
53
85
 
54
86
  Usage:
55
- belt frontend env <environment> Write frontend/.env from terraform outputs
87
+ belt frontend env <environment> [--frontend NAME]
88
+ belt frontend list
56
89
  belt frontend --help
57
90
 
58
- Env map (optional):
59
- frontend/env.yml
60
- .belt/frontend_env.yml
91
+ Env map (optional, per frontend):
92
+ <frontend>/env.yml
93
+ .belt/frontend_env.yml (default `frontend/` only)
94
+
95
+ Multiple frontends: config/frontends.yml (see `belt explain frontend`).
61
96
 
62
97
  Example map:
63
98
  VITE_API_URL: api_url
@@ -73,27 +108,29 @@ module Belt
73
108
  USAGE
74
109
  end
75
110
 
76
- def initialize(env)
111
+ def initialize(env, frontend: nil)
77
112
  @env = env
78
113
  @app_name = detect_app_name
79
114
  @env_dir = "infrastructure/#{@env}"
115
+ @frontend = frontend || FrontendRegistry.new.resolve!
80
116
  end
81
117
 
82
118
  def run
83
- unless Dir.exist?('frontend')
84
- abort 'Error: No frontend/ directory found. Run `belt generate frontend react` first.'
119
+ unless Dir.exist?(@frontend.path)
120
+ abort "Error: No #{@frontend.path}/ directory found. Run `belt generate frontend react` first."
85
121
  end
86
122
 
87
123
  unless Dir.exist?(@env_dir)
88
124
  abort "Error: infrastructure/#{@env} not found. Run `belt generate environment #{@env}` first."
89
125
  end
90
126
 
91
- map = FrontendEnvMap.new(@env, env_dir: @env_dir)
127
+ map = FrontendEnvMap.new(@env, env_dir: @env_dir, frontend_path: @frontend.path)
92
128
 
129
+ prefix = @frontend.name == 'frontend' ? '' : "[#{@frontend.name}] "
93
130
  if map.using_default_map?
94
- puts '📋 No frontend/env.yml found — using default map (VITE_API_URL ← api_url)'
131
+ puts "📋 #{prefix}No #{@frontend.path}/env.yml found — using default map (VITE_API_URL ← api_url)"
95
132
  else
96
- puts "📋 Loading env map from #{map.map_path}"
133
+ puts "📋 #{prefix}Loading env map from #{map.map_path}"
97
134
  end
98
135
 
99
136
  result = map.write_dotenv!
@@ -9,8 +9,10 @@ module Belt
9
9
  # Resolves frontend env vars from a declarative map (env name → terraform output).
10
10
  #
11
11
  # Map file (optional), first match wins:
12
- # frontend/env.yml
13
- # .belt/frontend_env.yml
12
+ # <frontend-path>/env.yml
13
+ # <frontend-path>/env.yaml
14
+ # .belt/frontend_env.yml (only for the default `frontend/` dir)
15
+ # .belt/frontend_env.yaml
14
16
  #
15
17
  # Example:
16
18
  # VITE_API_URL: api_url
@@ -30,18 +32,33 @@ module Belt
30
32
 
31
33
  DOTENV_PATH = File.join('frontend', '.env')
32
34
 
33
- attr_reader :env_name, :env_dir, :map_path, :map
35
+ attr_reader :env_name, :env_dir, :map_path, :map, :frontend_path
34
36
 
35
- def initialize(env_name, env_dir: nil, map_path: nil)
37
+ def initialize(env_name, env_dir: nil, map_path: nil, frontend_path: 'frontend')
36
38
  @env_name = env_name
37
39
  @env_dir = env_dir || File.join('infrastructure', env_name)
38
- @map_path = map_path || self.class.find_map_path
40
+ @frontend_path = frontend_path
41
+ @map_path = map_path || self.class.find_map_path(frontend_path)
39
42
  @map = load_map
40
43
  @tf_cache = {}
41
44
  end
42
45
 
43
- def self.find_map_path
44
- MAP_CANDIDATES.find { |path| File.file?(path) }
46
+ def self.find_map_path(dir = 'frontend')
47
+ candidates_for(dir).find { |path| File.file?(path) }
48
+ end
49
+
50
+ def self.candidates_for(dir)
51
+ list = [
52
+ File.join(dir, 'env.yml'),
53
+ File.join(dir, 'env.yaml')
54
+ ]
55
+ if dir == 'frontend'
56
+ list += [
57
+ File.join('.belt', 'frontend_env.yml'),
58
+ File.join('.belt', 'frontend_env.yaml')
59
+ ]
60
+ end
61
+ list
45
62
  end
46
63
 
47
64
  # Hash of env_var => value suitable for process env (npm run build / vite).
@@ -54,11 +71,12 @@ module Belt
54
71
  resolved
55
72
  end
56
73
 
57
- # Smart-merge mapped keys into frontend/.env.
74
+ # Smart-merge mapped keys into <frontend>/.env.
58
75
  # - Only overwrites keys present in the map
59
76
  # - Preserves unknown keys, comments, and blank lines
60
77
  # - Missing TF output → warn, do not clobber an existing value
61
- def write_dotenv!(path: DOTENV_PATH)
78
+ def write_dotenv!(path: nil)
79
+ path ||= File.join(@frontend_path, '.env')
62
80
  FileUtils.mkdir_p(File.dirname(path))
63
81
 
64
82
  existing_lines = File.file?(path) ? File.readlines(path, chomp: true) : []