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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +52 -0
- data/lib/belt/cli/auth_command.rb +27 -12
- data/lib/belt/cli/deploy_command.rb +27 -14
- data/lib/belt/cli/destroy_command.rb +55 -24
- data/lib/belt/cli/environment_command.rb +12 -0
- data/lib/belt/cli/explain_command.rb +3 -1
- data/lib/belt/cli/frontend_command.rb +31 -10
- data/lib/belt/cli/frontend_deploy_command.rb +110 -21
- data/lib/belt/cli/frontend_env_command.rb +55 -18
- data/lib/belt/cli/frontend_env_map.rb +27 -9
- data/lib/belt/cli/frontend_registry.rb +373 -0
- data/lib/belt/cli/frontend_setup_command.rb +71 -5
- data/lib/belt/cli/generate_command.rb +21 -6
- data/lib/belt/cli/routes_command/request_model_inference.rb +131 -0
- data/lib/belt/cli/routes_command.rb +7 -0
- data/lib/belt/cli/server_command.rb +28 -15
- data/lib/belt/cli/terraform_command.rb +9 -0
- data/lib/belt/cli/views_command.rb +13 -7
- data/lib/belt/cli/zip_artifact_builder.rb +199 -0
- data/lib/belt/cli.rb +12 -8
- data/lib/belt/docs/deployment.md +27 -2
- data/lib/belt/docs/frontend.md +103 -0
- data/lib/belt/docs/generators.md +9 -0
- data/lib/belt/docs/structure.md +3 -0
- data/lib/belt/route_dsl.rb +113 -23
- data/lib/belt/version.rb +1 -1
- data/lib/templates/frontend/react/env.yml.example +1 -1
- data/lib/templates/module/frontend.tf.erb +49 -43
- data/lib/templates/new_app/AGENTS.md.erb +29 -0
- metadata +5 -1
|
@@ -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.
|
|
23
|
+
frontend = name ? FrontendRegistry.new.resolve!(name) : nil
|
|
24
|
+
new(nil, frontend: frontend).run
|
|
21
25
|
end
|
|
22
26
|
|
|
23
|
-
def
|
|
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,
|
|
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
|
-
|
|
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}
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|