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.

@@ -0,0 +1,373 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'yaml'
5
+
6
+ module Belt
7
+ module CLI
8
+ # A named frontend application (SPA) in a Belt project.
9
+ #
10
+ # Belt apps historically used a single `frontend/` directory. Apps like
11
+ # Stowzilla have several (customer `app/`, ops `ops-app/`, …). This object
12
+ # is the per-frontend view of path, build output, and terraform outputs.
13
+ class Frontend
14
+ attr_reader :name, :dist, :bucket_output, :distribution_output,
15
+ :url_output, :cloudfront_domain_output
16
+ attr_accessor :default, :path
17
+
18
+ # rubocop:disable Metrics/ParameterLists -- keyword options from YAML config
19
+ def initialize(name:, path:, dist: nil, bucket_output: nil, distribution_output: nil,
20
+ url_output: nil, cloudfront_domain_output: nil, default: false)
21
+ @name = name.to_s
22
+ @path = path.to_s.sub(%r{/\z}, '')
23
+ @dist = dist
24
+ @default = default
25
+ @bucket_output = bucket_output || default_output('bucket_name')
26
+ @distribution_output_explicit = present_output?(distribution_output)
27
+ @distribution_output = @distribution_output_explicit ? distribution_output : default_output('distribution_id')
28
+ @url_output = url_output || default_output('url')
29
+ @cloudfront_domain_output = cloudfront_domain_output
30
+ end
31
+ # rubocop:enable Metrics/ParameterLists
32
+
33
+ def default?
34
+ @default
35
+ end
36
+
37
+ def distribution_output_explicit?
38
+ @distribution_output_explicit
39
+ end
40
+
41
+ def slug
42
+ self.class.slug(name)
43
+ end
44
+
45
+ def self.slug(name)
46
+ name.to_s.downcase.gsub(/[^a-z0-9]+/, '_').gsub(/\A_|_\z/, '')
47
+ end
48
+
49
+ # Terraform resource name: `frontend` for the default, `{slug}_frontend` otherwise.
50
+ def tf_name
51
+ slug == 'frontend' ? 'frontend' : "#{slug}_frontend"
52
+ end
53
+
54
+ def output_prefix
55
+ tf_name
56
+ end
57
+
58
+ def src_dir
59
+ File.join(path, 'src')
60
+ end
61
+
62
+ def package_json
63
+ File.join(path, 'package.json')
64
+ end
65
+
66
+ def app_jsx
67
+ File.join(src_dir, 'App.jsx')
68
+ end
69
+
70
+ def exists?
71
+ Dir.exist?(path) && File.file?(package_json)
72
+ end
73
+
74
+ # Build output directory. Explicit `dist:` wins; otherwise prefer `dist/`
75
+ # then `build/` (CRA / some Vite configs) then default `dist`.
76
+ def dist_dir
77
+ return File.join(path, @dist) if @dist && !@dist.to_s.empty?
78
+
79
+ %w[dist build].each do |dir|
80
+ candidate = File.join(path, dir)
81
+ return candidate if Dir.exist?(candidate)
82
+ end
83
+
84
+ File.join(path, 'dist')
85
+ end
86
+
87
+ def label
88
+ name == 'frontend' ? 'frontend' : "frontend '#{name}'"
89
+ end
90
+
91
+ def yaml_lines
92
+ lines = [" #{name}:", " path: #{path}"]
93
+ lines << " dist: #{dist}" if dist && !dist.to_s.empty?
94
+ lines << ' default: true' if default?
95
+ lines << " bucket_output: #{bucket_output}" if bucket_output != inferred_output('bucket_name')
96
+ if distribution_output != inferred_output('distribution_id')
97
+ lines << " distribution_output: #{distribution_output}"
98
+ end
99
+ lines << " url_output: #{url_output}" if url_output != inferred_output('url')
100
+ lines << " cloudfront_domain_output: #{cloudfront_domain_output}" if cloudfront_domain_output
101
+ lines
102
+ end
103
+
104
+ def inferred_output(suffix)
105
+ slug == 'frontend' ? "frontend_#{suffix}" : "#{slug}_frontend_#{suffix}"
106
+ end
107
+ alias default_output inferred_output
108
+
109
+ def present_output?(value)
110
+ !(value.nil? || value.to_s.empty?)
111
+ end
112
+ private :inferred_output, :default_output, :present_output?
113
+ end
114
+
115
+ # Discovers and resolves frontends for CLI commands.
116
+ #
117
+ # Config (first match wins):
118
+ # config/frontends.yml
119
+ # config/frontends.yaml
120
+ # .belt/frontends.yml
121
+ # .belt/frontends.yaml
122
+ #
123
+ # If no config exists and `frontend/` is present, that directory is the
124
+ # single implicit frontend (backwards compatible).
125
+ #
126
+ # Example:
127
+ #
128
+ # frontends:
129
+ # customer:
130
+ # path: app
131
+ # dist: build
132
+ # default: true
133
+ # bucket_output: web_app_bucket_name
134
+ # url_output: web_app_url
135
+ # cloudfront_domain_output: web_app_cloudfront_domain
136
+ # ops:
137
+ # path: ops-app
138
+ # dist: build
139
+ # bucket_output: ops_app_bucket_name
140
+ # url_output: ops_app_url
141
+ class FrontendRegistry
142
+ CONFIG_CANDIDATES = [
143
+ File.join('config', 'frontends.yml'),
144
+ File.join('config', 'frontends.yaml'),
145
+ File.join('.belt', 'frontends.yml'),
146
+ File.join('.belt', 'frontends.yaml')
147
+ ].freeze
148
+
149
+ WRITE_PATH = File.join('config', 'frontends.yml')
150
+
151
+ def self.find_config_path
152
+ CONFIG_CANDIDATES.find { |path| File.file?(path) }
153
+ end
154
+
155
+ # Pull `--flag VALUE` or `--flag=VALUE` out of args, mutating the array.
156
+ def self.extract_flag!(args, flag)
157
+ i = 0
158
+ while i < args.length
159
+ arg = args[i]
160
+ if arg == flag
161
+ args.delete_at(i)
162
+ return args.delete_at(i)
163
+ elsif arg.start_with?("#{flag}=")
164
+ args.delete_at(i)
165
+ return arg.split('=', 2).last
166
+ else
167
+ i += 1
168
+ end
169
+ end
170
+ nil
171
+ end
172
+
173
+ def self.load
174
+ new
175
+ end
176
+
177
+ # Add (or update) a frontend and persist config/frontends.yml.
178
+ def self.register!(name:, path:, default: nil)
179
+ registry = new
180
+ registry.add(name: name, path: path, default: default)
181
+ registry.write!
182
+ end
183
+
184
+ def initialize
185
+ @config_path = self.class.find_config_path
186
+ @frontends = load_frontends
187
+ end
188
+
189
+ def all
190
+ @frontends
191
+ end
192
+
193
+ def empty?
194
+ @frontends.empty?
195
+ end
196
+
197
+ def existing
198
+ @frontends.select(&:exists?)
199
+ end
200
+
201
+ def named(name)
202
+ return nil if name.nil? || name.to_s.empty?
203
+
204
+ key = name.to_s
205
+ slug = Frontend.slug(key)
206
+ @frontends.find { |fe| fe.name == key || fe.slug == slug }
207
+ end
208
+
209
+ # Default frontend: explicit `default: true`, otherwise the only one.
210
+ def default
211
+ @frontends.find(&:default?) || (@frontends.length == 1 ? @frontends.first : nil)
212
+ end
213
+
214
+ # Resolve a frontend for generate/server/auth. Aborts when ambiguous.
215
+ def resolve!(name = nil)
216
+ if name && !name.to_s.empty?
217
+ fe = named(name)
218
+ abort unknown_message(name) unless fe
219
+ return fe
220
+ end
221
+
222
+ fe = default
223
+ return fe if fe
224
+
225
+ abort empty_message if @frontends.empty?
226
+
227
+ abort multiple_message
228
+ end
229
+
230
+ def add(name:, path:, default: nil)
231
+ name = name.to_s
232
+ path = path.to_s.sub(%r{/\z}, '')
233
+ existing = named(name)
234
+
235
+ becomes_default = if !default.nil?
236
+ default
237
+ elsif existing
238
+ existing.default?
239
+ else
240
+ @frontends.empty?
241
+ end
242
+
243
+ @frontends.each { |fe| fe.default = false } if becomes_default
244
+
245
+ if existing
246
+ existing.default = becomes_default
247
+ existing.path = path
248
+ else
249
+ @frontends << Frontend.new(name: name, path: path, default: becomes_default)
250
+ end
251
+
252
+ named(name)
253
+ end
254
+
255
+ def write!
256
+ dest = @config_path || WRITE_PATH
257
+ FileUtils.mkdir_p(File.dirname(dest))
258
+ File.write(dest, dump_yaml)
259
+ @config_path = dest
260
+ dest
261
+ end
262
+
263
+ def unknown_message(name)
264
+ "Error: Unknown frontend '#{name}'. #{known_suffix}"
265
+ end
266
+
267
+ def multiple_message
268
+ "Error: Multiple frontends found (#{names.join(', ')}). " \
269
+ 'Specify one with --frontend <name>.'
270
+ end
271
+
272
+ def empty_message
273
+ 'Error: No frontend found. Run `belt generate frontend react` first, ' \
274
+ 'or add config/frontends.yml.'
275
+ end
276
+
277
+ def names
278
+ @frontends.map(&:name)
279
+ end
280
+
281
+ private
282
+
283
+ def load_frontends
284
+ if @config_path
285
+ parsed = parse_config(@config_path)
286
+ return implicit_frontends if parsed.empty?
287
+
288
+ parsed
289
+ else
290
+ implicit_frontends
291
+ end
292
+ end
293
+
294
+ def implicit_frontends
295
+ return [] unless Dir.exist?('frontend')
296
+
297
+ [Frontend.new(name: 'frontend', path: 'frontend', default: true)]
298
+ end
299
+
300
+ def parse_config(path)
301
+ raw = YAML.safe_load_file(path, aliases: false)
302
+ return [] if raw.nil? || raw == false
303
+
304
+ abort "Error: #{path} must be a YAML mapping of frontend names." unless raw.is_a?(Hash)
305
+
306
+ entries = raw['frontends'].is_a?(Hash) ? raw['frontends'] : raw
307
+ unless entries.is_a?(Hash)
308
+ abort "Error: #{path} must map frontend names to settings " \
309
+ '(e.g. frontends: { customer: { path: app } }).'
310
+ end
311
+
312
+ entries.filter_map do |name, settings|
313
+ next if name.to_s == 'frontends' && settings.is_a?(Hash) && raw.key?('frontends')
314
+
315
+ attrs = normalize_settings(name, settings)
316
+ next unless attrs
317
+
318
+ Frontend.new(**attrs)
319
+ end
320
+ rescue Psych::SyntaxError => e
321
+ abort "Error: invalid YAML in #{path}: #{e.message}"
322
+ end
323
+
324
+ def normalize_settings(name, settings)
325
+ case settings
326
+ when Hash
327
+ {
328
+ name: name.to_s,
329
+ path: (settings['path'] || settings[:path] || name).to_s,
330
+ dist: settings['dist'] || settings[:dist],
331
+ default: truthy?(settings['default'] || settings[:default]),
332
+ bucket_output: settings['bucket_output'] || settings[:bucket_output],
333
+ distribution_output: settings['distribution_output'] || settings[:distribution_output],
334
+ url_output: settings['url_output'] || settings[:url_output],
335
+ cloudfront_domain_output: settings['cloudfront_domain_output'] ||
336
+ settings[:cloudfront_domain_output]
337
+ }
338
+ when String
339
+ { name: name.to_s, path: settings }
340
+ when nil
341
+ { name: name.to_s, path: name.to_s }
342
+ else
343
+ abort "Error: frontend '#{name}' in #{@config_path} must be a mapping " \
344
+ '(path:, dist:, …) or a directory path string.'
345
+ end
346
+ end
347
+
348
+ def truthy?(value)
349
+ value == true || value.to_s.downcase == 'true'
350
+ end
351
+
352
+ def dump_yaml
353
+ lines = [
354
+ '# Belt frontend registry',
355
+ '# Used by `belt deploy frontend`, generators, and `belt server`.',
356
+ '# See `belt explain frontend`.',
357
+ '',
358
+ 'frontends:'
359
+ ]
360
+ @frontends.each { |fe| lines.concat(fe.yaml_lines) }
361
+ "#{lines.join("\n")}\n"
362
+ end
363
+
364
+ def known_suffix
365
+ if @frontends.empty?
366
+ 'No frontends are configured.'
367
+ else
368
+ "Known frontends: #{names.join(', ')}"
369
+ end
370
+ end
371
+ end
372
+ end
373
+ end
@@ -4,6 +4,7 @@ require 'fileutils'
4
4
  require 'erb'
5
5
  require_relative 'app_detection'
6
6
  require_relative 'env_resolver'
7
+ require_relative 'frontend_registry'
7
8
 
8
9
  module Belt
9
10
  module CLI
@@ -16,19 +17,61 @@ module Belt
16
17
  def self.run(args)
17
18
  # Environment arg is no longer needed since frontend.tf goes in the module,
18
19
  # but we still accept it for backwards compat (just ignore it).
20
+ name = FrontendRegistry.extract_flag!(args, '--name')
21
+ name ||= FrontendRegistry.extract_flag!(args, '--frontend')
19
22
  _env = EnvResolver.resolve(args)
20
- new.run
23
+ frontend = name ? FrontendRegistry.new.resolve!(name) : nil
24
+ new(nil, frontend: frontend).run
21
25
  end
22
26
 
23
- def initialize(_env = nil, quiet: false)
27
+ def self.append_env_outputs_for(frontend, outputs_file)
28
+ return unless File.exist?(outputs_file)
29
+ return if frontend.tf_name == 'frontend'
30
+
31
+ content = File.read(outputs_file)
32
+ prefix = frontend.output_prefix
33
+ return if content.include?("output \"#{prefix}_bucket_name\"")
34
+
35
+ File.write(outputs_file, content + extra_env_outputs(frontend))
36
+ end
37
+
38
+ def self.extra_env_outputs(frontend)
39
+ prefix = frontend.output_prefix
40
+ label = frontend.name
41
+ <<~HCL
42
+
43
+ output "#{prefix}_bucket_name" {
44
+ description = "S3 bucket for #{label} frontend assets"
45
+ value = try(module.app.#{prefix}_bucket_name, "")
46
+ }
47
+
48
+ output "#{prefix}_distribution_id" {
49
+ description = "CloudFront distribution ID for #{label} frontend"
50
+ value = try(module.app.#{prefix}_distribution_id, "")
51
+ }
52
+
53
+ output "#{prefix}_url" {
54
+ description = "#{label} frontend URL"
55
+ value = try(module.app.#{prefix}_url, "")
56
+ }
57
+ HCL
58
+ end
59
+
60
+ def initialize(_env = nil, quiet: false, frontend: nil)
24
61
  @app_name = detect_app_name
25
62
  @quiet = quiet
63
+ @frontend = frontend || Frontend.new(name: 'frontend', path: 'frontend', default: true)
64
+ @tf_name = @frontend.tf_name
65
+ @output_prefix = @frontend.output_prefix
66
+ @include_dns = @frontend.slug == 'frontend'
67
+ @bucket_slug = @frontend.slug == 'frontend' ? 'frontend' : "#{@frontend.slug.tr('_', '-')}-frontend"
26
68
  end
27
69
 
28
70
  def run
29
71
  validate!
30
72
  generate_frontend_tf
31
73
  ensure_cloudfront_cors
74
+ append_env_outputs
32
75
  return if @quiet
33
76
 
34
77
  puts "\n✓ Frontend infrastructure generated in #{MODULE_DIR}!"
@@ -46,13 +89,24 @@ module Belt
46
89
  end
47
90
 
48
91
  def generate_frontend_tf
49
- dest = File.join(MODULE_DIR, 'frontend.tf')
92
+ dest = File.join(MODULE_DIR, "#{@tf_name}.tf")
50
93
  template_path = File.join(TEMPLATE_DIR, 'frontend.tf.erb')
51
94
  content = ERB.new(File.read(template_path), trim_mode: '-').result(binding)
52
95
  File.write(dest, content)
53
96
  puts " create #{dest}" unless @quiet
54
97
  end
55
98
 
99
+ def append_env_outputs
100
+ Dir.glob('infrastructure/*/outputs.tf').reject { |f| f.include?('modules') }.each do |file|
101
+ before = File.read(file)
102
+ self.class.append_env_outputs_for(@frontend, file)
103
+ next if File.read(file) == before
104
+
105
+ env_name = File.basename(File.dirname(file))
106
+ puts " update #{file} (#{@frontend.name} frontend outputs for #{env_name})" unless @quiet
107
+ end
108
+ end
109
+
56
110
  # Wire CloudFront origin into conveyor_belt frontend_urls so SPA→API CORS works.
57
111
  # Handles common scaffold shapes so users never need the tutorial's manual CORS fix.
58
112
  def ensure_cloudfront_cors
@@ -60,13 +114,25 @@ module Belt
60
114
  return unless File.exist?(main_tf)
61
115
 
62
116
  content = File.read(main_tf)
63
- return if content.include?('aws_cloudfront_distribution.frontend.domain_name')
117
+ ref = "aws_cloudfront_distribution.#{@tf_name}.domain_name"
118
+ return if content.include?(ref)
119
+
120
+ cf_line = %(["https://${#{ref}}"])
121
+
122
+ # Already a concat of CloudFront origins — insert this distribution.
123
+ if content.match?(/frontend_urls\s*=\s*concat\(/) &&
124
+ content.include?('aws_cloudfront_distribution.')
125
+ content = content.sub(/(frontend_urls\s*=\s*concat\(\n)/, "\\1 #{cf_line},\n")
126
+ File.write(main_tf, content)
127
+ puts " update #{main_tf} (CloudFront CORS: #{@tf_name})" unless @quiet
128
+ return
129
+ end
64
130
 
65
131
  replacement = lambda do |indent|
66
132
  <<~TF.chomp
67
133
  #{indent}# CloudFront first so SPA→API CORS works out of the box.
68
134
  #{indent}frontend_urls = concat(
69
- #{indent} ["https://${aws_cloudfront_distribution.frontend.domain_name}"],
135
+ #{indent} #{cf_line},
70
136
  #{indent} var.frontend_urls
71
137
  #{indent})
72
138
  TF
@@ -6,6 +6,7 @@ require_relative 'app_detection'
6
6
  require_relative 'auth_command'
7
7
  require_relative 'environment_command'
8
8
  require_relative 'frontend_command'
9
+ require_relative 'frontend_registry'
9
10
  require_relative 'tables_command'
10
11
  require_relative 'views_command'
11
12
  require_relative 'generator_registry'
@@ -28,12 +29,14 @@ module Belt
28
29
  usage: 'belt generate scaffold <name> [field:type ...] [options]',
29
30
  options: [
30
31
  ['--skip-views', 'Skip generating frontend view pages'],
32
+ ['--frontend NAME', 'Target frontend when several exist'],
31
33
  ['--force, -f', 'Overwrite existing resource files (skip collision check)']
32
34
  ],
33
35
  examples: [
34
36
  ['belt g scaffold post title body:text'],
35
37
  ['belt g scaffold comment post:references body:text'],
36
38
  ['belt g scaffold task --skip-views'],
39
+ ['belt g scaffold bag --frontend ops'],
37
40
  ['belt g scaffold post title body:text --force']
38
41
  ],
39
42
  notes: <<~NOTES
@@ -44,7 +47,7 @@ module Belt
44
47
  config/contracts.rb API response contract added
45
48
  lambda/lib/routes/<app>_routes.rb Route manifest updated
46
49
  infrastructure/modules/app/dynamodb.tf DynamoDB table generated
47
- frontend/src/pages/<names>/ React pages (if frontend exists)
50
+ <frontend>/src/pages/<names>/ React pages (if a frontend exists)
48
51
 
49
52
  Nested Resources:
50
53
  Use `<parent>:references` to create a nested resource. This will:
@@ -142,8 +145,10 @@ module Belt
142
145
 
143
146
  force = args.delete('--force') || args.delete('-f')
144
147
  skip_views = args.delete('--skip-views')
148
+ frontend_name = FrontendRegistry.extract_flag!(args, '--frontend')
145
149
  fields = args.map { |arg| parse_field(arg) }
146
- new(generator, name, fields, skip_views: skip_views, force: force).generate
150
+ new(generator, name, fields, skip_views: skip_views, force: force,
151
+ frontend_name: frontend_name).generate
147
152
  end
148
153
 
149
154
  def self.parse_field(arg)
@@ -200,7 +205,7 @@ module Belt
200
205
  controller Generate a controller
201
206
  auth Generate Cognito user pool infrastructure
202
207
  environment Create a new deployment environment
203
- frontend Scaffold a frontend app (react, vue, svelte)
208
+ frontend Scaffold a frontend app (react, vue, svelte; --name / --path for extras)
204
209
  views Generate React pages for a resource
205
210
 
206
211
  Aliases:
@@ -228,7 +233,9 @@ module Belt
228
233
  belt g controller comments
229
234
  belt g environment staging
230
235
  belt g frontend react
236
+ belt g frontend react --name ops --path ops-app
231
237
  belt g views post title body:text
238
+ belt g views bag --frontend ops
232
239
 
233
240
  Run 'belt generate <generator> --help' for detailed help on a specific generator.
234
241
  HELP
@@ -271,12 +278,14 @@ module Belt
271
278
  puts "\n#{info[:notes]}" if info[:notes]
272
279
  end
273
280
 
274
- def initialize(generator, name, fields, skip_views: false, force: false)
281
+ # rubocop:disable Metrics/ParameterLists -- keyword options for generator flags
282
+ def initialize(generator, name, fields, skip_views: false, force: false, frontend_name: nil)
275
283
  @generator = generator
276
284
  @name = name.downcase.gsub(/[^a-z0-9_]/, '_')
277
285
  @fields = fields
278
286
  @skip_views = skip_views
279
287
  @force = force
288
+ @frontend_name = frontend_name
280
289
  @app_name = detect_namespace
281
290
  @module_name = @app_name.split(/[-_]/).map(&:capitalize).join
282
291
  @singular_name = Belt::Inflector.singularize(@name)
@@ -284,6 +293,7 @@ module Belt
284
293
  @class_name = Belt::Inflector.classify(@singular_name)
285
294
  @references, @regular_fields = @fields.partition { |f| f[:type] == 'references' }
286
295
  end
296
+ # rubocop:enable Metrics/ParameterLists
287
297
 
288
298
  def generate
289
299
  case @generator
@@ -629,10 +639,15 @@ module Belt
629
639
  end
630
640
 
631
641
  def generate_views_if_frontend
632
- return unless Dir.exist?('frontend/src')
633
642
  return if @skip_views
634
643
 
635
- Belt::CLI::ViewsCommand.new(@name, @fields, force: @force, quiet: true).generate
644
+ registry = FrontendRegistry.new
645
+ return if registry.empty?
646
+
647
+ frontend = registry.resolve!(@frontend_name)
648
+ return unless Dir.exist?(frontend.src_dir)
649
+
650
+ Belt::CLI::ViewsCommand.new(@name, @fields, force: @force, quiet: true, frontend: frontend).generate
636
651
  end
637
652
  end
638
653
  end