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 +4 -4
- data/CHANGELOG.md +10 -0
- data/lib/belt/cli/deploy_command.rb +40 -0
- data/lib/belt/cli/destroy_command.rb +8 -28
- data/lib/belt/cli/environment_command.rb +4 -12
- data/lib/belt/cli/frontend_command.rb +11 -13
- data/lib/belt/cli/frontend_deploy_command.rb +21 -13
- data/lib/belt/cli/frontend_env_command.rb +110 -0
- data/lib/belt/cli/frontend_env_map.rb +188 -0
- data/lib/belt/cli/frontend_setup_command.rb +14 -55
- data/lib/belt/cli/generate_command.rb +73 -10
- data/lib/belt/cli/new_command.rb +82 -33
- data/lib/belt/cli/server_command.rb +25 -7
- data/lib/belt/cli/setup_command.rb +2 -8
- data/lib/belt/cli/tables_command.rb +16 -21
- data/lib/belt/cli.rb +6 -0
- data/lib/belt/version.rb +1 -1
- data/lib/templates/environment/backend.tf.erb +2 -2
- data/lib/templates/environment/main.tf.erb +14 -18
- data/lib/templates/environment/outputs.tf.erb +28 -3
- data/lib/templates/environment/terraform.tfvars.erb +5 -0
- data/lib/templates/environment/variables.tf.erb +6 -0
- data/lib/templates/frontend/react/env.yml.example +13 -0
- data/lib/templates/module/dns.tf.erb +83 -0
- data/lib/templates/module/frontend.tf.erb +179 -0
- data/lib/templates/module/main.tf.erb +42 -0
- data/lib/templates/module/outputs.tf.erb +24 -0
- data/lib/templates/module/variables.tf.erb +27 -0
- data/lib/templates/views/Form.jsx.erb +63 -7
- data/lib/templates/views/Index.jsx.erb +41 -10
- data/lib/templates/views/Show.jsx.erb +12 -2
- metadata +9 -1
|
@@ -8,90 +8,49 @@ require_relative 'env_resolver'
|
|
|
8
8
|
module Belt
|
|
9
9
|
module CLI
|
|
10
10
|
class FrontendSetupCommand
|
|
11
|
-
TEMPLATE_DIR = File.expand_path('../../templates/
|
|
11
|
+
TEMPLATE_DIR = File.expand_path('../../templates/module', __dir__)
|
|
12
|
+
MODULE_DIR = 'infrastructure/modules/app'
|
|
12
13
|
|
|
13
14
|
include AppDetection
|
|
14
15
|
|
|
15
16
|
def self.run(args)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
puts "\nGenerates S3 + CloudFront Terraform for frontend hosting."
|
|
21
|
-
puts 'You can also set BELT_ENV to skip the environment argument.'
|
|
22
|
-
puts "\nExamples:"
|
|
23
|
-
puts ' belt setup frontend wups'
|
|
24
|
-
puts ' belt setup frontend dev01'
|
|
25
|
-
puts ' BELT_ENV=wups belt setup frontend'
|
|
26
|
-
exit 1
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
new(env).run
|
|
17
|
+
# Environment arg is no longer needed since frontend.tf goes in the module,
|
|
18
|
+
# but we still accept it for backwards compat (just ignore it).
|
|
19
|
+
_env = EnvResolver.resolve(args)
|
|
20
|
+
new.run
|
|
30
21
|
end
|
|
31
22
|
|
|
32
|
-
def initialize(env, quiet: false)
|
|
33
|
-
@env = env
|
|
23
|
+
def initialize(env = nil, quiet: false)
|
|
34
24
|
@app_name = detect_app_name
|
|
35
|
-
@env_dir = "infrastructure/#{@env}"
|
|
36
25
|
@quiet = quiet
|
|
37
26
|
end
|
|
38
27
|
|
|
39
28
|
def run
|
|
40
29
|
validate!
|
|
41
30
|
generate_frontend_tf
|
|
42
|
-
update_main_tf_frontend_urls
|
|
43
31
|
return if @quiet
|
|
44
32
|
|
|
45
|
-
puts "\n✓ Frontend infrastructure generated
|
|
46
|
-
puts "\nRun `belt
|
|
47
|
-
puts "Then `belt deploy frontend
|
|
33
|
+
puts "\n✓ Frontend infrastructure generated in #{MODULE_DIR}!"
|
|
34
|
+
puts "\nRun `belt deploy` to create the S3 bucket and CloudFront distribution."
|
|
35
|
+
puts "Then `belt deploy frontend` to build and deploy."
|
|
48
36
|
end
|
|
49
37
|
|
|
50
38
|
private
|
|
51
39
|
|
|
52
40
|
def validate!
|
|
53
|
-
return if Dir.exist?(
|
|
41
|
+
return if Dir.exist?(MODULE_DIR)
|
|
54
42
|
|
|
55
|
-
abort "Error:
|
|
56
|
-
"
|
|
43
|
+
abort "Error: Module directory not found at #{MODULE_DIR}/.\n" \
|
|
44
|
+
"Run `belt new` to create a project with the correct structure."
|
|
57
45
|
end
|
|
58
46
|
|
|
59
47
|
def generate_frontend_tf
|
|
60
|
-
dest = File.join(
|
|
48
|
+
dest = File.join(MODULE_DIR, 'frontend.tf')
|
|
61
49
|
template_path = File.join(TEMPLATE_DIR, 'frontend.tf.erb')
|
|
62
50
|
content = ERB.new(File.read(template_path), trim_mode: '-').result(binding)
|
|
63
51
|
File.write(dest, content)
|
|
64
52
|
puts " create #{dest}" unless @quiet
|
|
65
53
|
end
|
|
66
|
-
|
|
67
|
-
def update_main_tf_frontend_urls
|
|
68
|
-
main_tf = File.join(@env_dir, 'main.tf')
|
|
69
|
-
return unless File.exist?(main_tf)
|
|
70
|
-
|
|
71
|
-
content = File.read(main_tf)
|
|
72
|
-
cloudfront_url = '"https://${aws_cloudfront_distribution.frontend.domain_name}"'
|
|
73
|
-
|
|
74
|
-
# Already patched — skip
|
|
75
|
-
return if content.include?('aws_cloudfront_distribution.frontend.domain_name')
|
|
76
|
-
|
|
77
|
-
# Match any frontend_urls line (hardcoded list or conditional expression)
|
|
78
|
-
new_frontend_urls = <<~HCL.chomp
|
|
79
|
-
frontend_urls = concat(
|
|
80
|
-
[#{cloudfront_url}],
|
|
81
|
-
var.environment == "prod" ? [] : ["http://localhost:3000"]
|
|
82
|
-
)
|
|
83
|
-
HCL
|
|
84
|
-
|
|
85
|
-
replaced = content.sub(
|
|
86
|
-
/^(\s*)frontend_urls\s*=\s*.+$/,
|
|
87
|
-
new_frontend_urls
|
|
88
|
-
)
|
|
89
|
-
|
|
90
|
-
if replaced != content
|
|
91
|
-
File.write(main_tf, replaced)
|
|
92
|
-
puts " update #{main_tf} (added CloudFront URL to frontend_urls)" unless @quiet
|
|
93
|
-
end
|
|
94
|
-
end
|
|
95
54
|
end
|
|
96
55
|
end
|
|
97
56
|
end
|
|
@@ -24,12 +24,14 @@ module Belt
|
|
|
24
24
|
description: 'Generate a model, controller, routes, schema, and views for a REST resource.',
|
|
25
25
|
usage: 'belt generate scaffold <name> [field:type ...] [options]',
|
|
26
26
|
options: [
|
|
27
|
-
['--skip-views', 'Skip generating frontend view pages']
|
|
27
|
+
['--skip-views', 'Skip generating frontend view pages'],
|
|
28
|
+
['--force, -f', 'Overwrite existing resource files (skip collision check)']
|
|
28
29
|
],
|
|
29
30
|
examples: [
|
|
30
31
|
['belt g scaffold post title body:text'],
|
|
31
32
|
['belt g scaffold comment author body:text status'],
|
|
32
|
-
['belt g scaffold task --skip-views']
|
|
33
|
+
['belt g scaffold task --skip-views'],
|
|
34
|
+
['belt g scaffold post title body:text --force']
|
|
33
35
|
],
|
|
34
36
|
notes: <<~NOTES
|
|
35
37
|
Creates:
|
|
@@ -45,8 +47,10 @@ module Belt
|
|
|
45
47
|
},
|
|
46
48
|
'model' => {
|
|
47
49
|
description: 'Generate an ActiveItem model with validations and DynamoDB field definitions.',
|
|
48
|
-
usage: 'belt generate model <name> [field:type ...]',
|
|
49
|
-
options: [
|
|
50
|
+
usage: 'belt generate model <name> [field:type ...] [options]',
|
|
51
|
+
options: [
|
|
52
|
+
['--force, -f', 'Overwrite existing model files (skip collision check)']
|
|
53
|
+
],
|
|
50
54
|
examples: [
|
|
51
55
|
['belt g model user email name'],
|
|
52
56
|
['belt g model event title starts_at:datetime']
|
|
@@ -110,9 +114,10 @@ module Belt
|
|
|
110
114
|
|
|
111
115
|
validate_resource_name!(name, generator)
|
|
112
116
|
|
|
117
|
+
force = args.delete('--force') || args.delete('-f')
|
|
113
118
|
skip_views = args.delete('--skip-views')
|
|
114
119
|
fields = args.map { |arg| parse_field(arg) }
|
|
115
|
-
new(generator, name, fields, skip_views: skip_views).generate
|
|
120
|
+
new(generator, name, fields, skip_views: skip_views, force: force).generate
|
|
116
121
|
end
|
|
117
122
|
|
|
118
123
|
def self.parse_field(arg)
|
|
@@ -214,11 +219,12 @@ module Belt
|
|
|
214
219
|
puts "\n#{info[:notes]}" if info[:notes]
|
|
215
220
|
end
|
|
216
221
|
|
|
217
|
-
def initialize(generator, name, fields, skip_views: false)
|
|
222
|
+
def initialize(generator, name, fields, skip_views: false, force: false)
|
|
218
223
|
@generator = generator
|
|
219
224
|
@name = name.downcase.gsub(/[^a-z0-9_]/, '_')
|
|
220
225
|
@fields = fields
|
|
221
226
|
@skip_views = skip_views
|
|
227
|
+
@force = force
|
|
222
228
|
@app_name = detect_namespace
|
|
223
229
|
@module_name = @app_name.split(/[-_]/).map(&:capitalize).join
|
|
224
230
|
@singular_name = Belt::Inflector.singularize(@name)
|
|
@@ -228,14 +234,60 @@ module Belt
|
|
|
228
234
|
|
|
229
235
|
def generate
|
|
230
236
|
case @generator
|
|
231
|
-
when 'scaffold'
|
|
232
|
-
|
|
237
|
+
when 'scaffold'
|
|
238
|
+
check_resource_collision! unless @force
|
|
239
|
+
generate_resource
|
|
240
|
+
when 'model'
|
|
241
|
+
check_model_collision! unless @force
|
|
242
|
+
generate_model_standalone
|
|
233
243
|
when 'controller' then generate_controller
|
|
234
244
|
end
|
|
235
245
|
end
|
|
236
246
|
|
|
237
247
|
private
|
|
238
248
|
|
|
249
|
+
def check_resource_collision!
|
|
250
|
+
conflicts = []
|
|
251
|
+
conflicts << "lambda/models/#{@singular_name}.rb" if File.exist?("lambda/models/#{@singular_name}.rb")
|
|
252
|
+
conflicts << "lambda/controllers/#{@app_name}/#{@resource_name}_controller.rb" if File.exist?("lambda/controllers/#{@app_name}/#{@resource_name}_controller.rb")
|
|
253
|
+
|
|
254
|
+
schema_file = 'infrastructure/schema.tf.rb'
|
|
255
|
+
if File.exist?(schema_file) && File.read(schema_file).match?(/^\s*model :#{Regexp.escape(@singular_name)}\b/)
|
|
256
|
+
conflicts << "#{schema_file} (model :#{@singular_name})"
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
routes_file = 'infrastructure/routes.tf.rb'
|
|
260
|
+
if File.exist?(routes_file) && File.read(routes_file).match?(/resources :#{Regexp.escape(@resource_name)}\b/)
|
|
261
|
+
conflicts << "#{routes_file} (resources :#{@resource_name})"
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
return if conflicts.empty?
|
|
265
|
+
|
|
266
|
+
puts "\n✗ Resource '#{@singular_name}' already exists. Conflicting files:"
|
|
267
|
+
conflicts.each { |c| puts " • #{c}" }
|
|
268
|
+
puts "\nTo overwrite, run again with --force:"
|
|
269
|
+
puts " belt g scaffold #{@singular_name} #{@fields.map { |f| "#{f[:name]}:#{f[:type]}" }.join(' ')} --force"
|
|
270
|
+
exit 1
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def check_model_collision!
|
|
274
|
+
conflicts = []
|
|
275
|
+
conflicts << "lambda/models/#{@singular_name}.rb" if File.exist?("lambda/models/#{@singular_name}.rb")
|
|
276
|
+
|
|
277
|
+
schema_file = 'infrastructure/schema.tf.rb'
|
|
278
|
+
if File.exist?(schema_file) && File.read(schema_file).match?(/^\s*model :#{Regexp.escape(@singular_name)}\b/)
|
|
279
|
+
conflicts << "#{schema_file} (model :#{@singular_name})"
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
return if conflicts.empty?
|
|
283
|
+
|
|
284
|
+
puts "\n✗ Model '#{@singular_name}' already exists. Conflicting files:"
|
|
285
|
+
conflicts.each { |c| puts " • #{c}" }
|
|
286
|
+
puts "\nTo overwrite, run again with --force:"
|
|
287
|
+
puts " belt g model #{@singular_name} #{@fields.map { |f| "#{f[:name]}:#{f[:type]}" }.join(' ')} --force"
|
|
288
|
+
exit 1
|
|
289
|
+
end
|
|
290
|
+
|
|
239
291
|
def generate_resource
|
|
240
292
|
generate_model
|
|
241
293
|
generate_controller
|
|
@@ -279,8 +331,15 @@ module Belt
|
|
|
279
331
|
tables_arg = @fields.any? ? ", tables: [:#{@resource_name}]" : ''
|
|
280
332
|
resource_line = "resources :#{@resource_name}#{tables_arg}"
|
|
281
333
|
|
|
334
|
+
# If this resource already exists in routes (force mode), replace it
|
|
335
|
+
existing_resource_pattern = /^\s*resources :#{Regexp.escape(@resource_name)}\b[^\n]*/
|
|
336
|
+
if content.match?(existing_resource_pattern)
|
|
337
|
+
content.sub!(existing_resource_pattern) do |match|
|
|
338
|
+
indent = match[/^\s*/]
|
|
339
|
+
"#{indent}#{resource_line}"
|
|
340
|
+
end
|
|
282
341
|
# Replace the commented placeholder if it exists
|
|
283
|
-
|
|
342
|
+
elsif content.include?('# resources :posts')
|
|
284
343
|
content.sub!('# resources :posts', resource_line)
|
|
285
344
|
else
|
|
286
345
|
# Find the target namespace block and insert before its closing `end`
|
|
@@ -368,8 +427,12 @@ module Belt
|
|
|
368
427
|
|
|
369
428
|
schema_block = " model :#{@singular_name} do\n#{field_lines.join("\n")}\n end\n"
|
|
370
429
|
|
|
430
|
+
# If model already exists (force mode), replace it
|
|
431
|
+
existing_model_pattern = /^ model :#{Regexp.escape(@singular_name)} do\n.*?^ end\n/m
|
|
432
|
+
if content.match?(existing_model_pattern)
|
|
433
|
+
content.sub!(existing_model_pattern, schema_block)
|
|
371
434
|
# Replace commented-out block or insert before final end
|
|
372
|
-
|
|
435
|
+
elsif content.match?(/^\s*#\s*model :/)
|
|
373
436
|
content.gsub!(/^\s*#[^\n]*\n/, '')
|
|
374
437
|
content.sub!(/^(end\s*\z)/m, "#{schema_block}\\1")
|
|
375
438
|
else
|
data/lib/belt/cli/new_command.rb
CHANGED
|
@@ -17,6 +17,7 @@ module Belt
|
|
|
17
17
|
frontend = nil
|
|
18
18
|
bucket = nil
|
|
19
19
|
environments = nil
|
|
20
|
+
domain = nil
|
|
20
21
|
|
|
21
22
|
i = 0
|
|
22
23
|
while i < args.length
|
|
@@ -43,6 +44,11 @@ module Belt
|
|
|
43
44
|
environments = args[i]
|
|
44
45
|
when /^--environments=/
|
|
45
46
|
environments = arg.split('=', 2).last
|
|
47
|
+
when '--domain'
|
|
48
|
+
i += 1
|
|
49
|
+
domain = args[i]
|
|
50
|
+
when /^--domain=/
|
|
51
|
+
domain = arg.split('=', 2).last
|
|
46
52
|
else
|
|
47
53
|
app_name ||= arg unless arg.start_with?('-')
|
|
48
54
|
end
|
|
@@ -54,6 +60,7 @@ module Belt
|
|
|
54
60
|
puts ''
|
|
55
61
|
puts 'Options:'
|
|
56
62
|
puts ' --frontend react|vue|svelte Set up frontend framework'
|
|
63
|
+
puts ' --domain DOMAIN Custom domain (e.g., myapp.com)'
|
|
57
64
|
puts ' --bucket BUCKET_NAME S3 bucket for Terraform state'
|
|
58
65
|
puts ' --state-bucket BUCKET_NAME Alias for --bucket'
|
|
59
66
|
puts ' --environments dev,prod Comma-separated environments (default: dev,prod)'
|
|
@@ -61,14 +68,15 @@ module Belt
|
|
|
61
68
|
exit 1
|
|
62
69
|
end
|
|
63
70
|
|
|
64
|
-
new(app_name, frontend: frontend, bucket: bucket, environments: environments).generate
|
|
71
|
+
new(app_name, frontend: frontend, bucket: bucket, environments: environments, domain: domain).generate
|
|
65
72
|
end
|
|
66
73
|
|
|
67
|
-
def initialize(app_name, frontend: nil, bucket: nil, environments: nil)
|
|
74
|
+
def initialize(app_name, frontend: nil, bucket: nil, environments: nil, domain: nil)
|
|
68
75
|
@app_name = app_name.gsub(/[^a-z0-9_-]/i, '_').downcase
|
|
69
76
|
@module_name = @app_name.split(/[-_]/).map(&:capitalize).join
|
|
70
77
|
@frontend = frontend
|
|
71
78
|
@bucket = bucket
|
|
79
|
+
@domain = domain
|
|
72
80
|
@environments = parse_environments(environments)
|
|
73
81
|
@resolved_bucket = nil
|
|
74
82
|
@state_setup_succeeded = false
|
|
@@ -82,6 +90,7 @@ module Belt
|
|
|
82
90
|
|
|
83
91
|
puts "Creating new Belt application: #{@app_name}"
|
|
84
92
|
create_structure
|
|
93
|
+
generate_module
|
|
85
94
|
generate_environments
|
|
86
95
|
generate_frontend if @frontend
|
|
87
96
|
init_git
|
|
@@ -89,6 +98,7 @@ module Belt
|
|
|
89
98
|
setup_state
|
|
90
99
|
puts "\n✓ #{@app_name} created successfully!"
|
|
91
100
|
print_next_steps
|
|
101
|
+
enter_project!
|
|
92
102
|
end
|
|
93
103
|
|
|
94
104
|
private
|
|
@@ -112,7 +122,7 @@ module Belt
|
|
|
112
122
|
#{@app_name}/lambda/lib/routes
|
|
113
123
|
#{@app_name}/lambda/config
|
|
114
124
|
#{@app_name}/lambda/spec
|
|
115
|
-
#{@app_name}/infrastructure
|
|
125
|
+
#{@app_name}/infrastructure/modules/app
|
|
116
126
|
]
|
|
117
127
|
end
|
|
118
128
|
|
|
@@ -134,12 +144,32 @@ module Belt
|
|
|
134
144
|
}
|
|
135
145
|
end
|
|
136
146
|
|
|
147
|
+
def generate_module
|
|
148
|
+
module_dir = "#{@app_name}/infrastructure/modules/app"
|
|
149
|
+
module_template_dir = File.expand_path('../../templates/module', File.dirname(__FILE__))
|
|
150
|
+
|
|
151
|
+
module_templates = {
|
|
152
|
+
'main.tf.erb' => 'main.tf',
|
|
153
|
+
'variables.tf.erb' => 'variables.tf',
|
|
154
|
+
'outputs.tf.erb' => 'outputs.tf',
|
|
155
|
+
'dns.tf.erb' => 'dns.tf'
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
module_templates.each do |template_name, dest_file|
|
|
159
|
+
dest_path = File.join(module_dir, dest_file)
|
|
160
|
+
template_path = File.join(module_template_dir, template_name)
|
|
161
|
+
content = ERB.new(File.read(template_path), trim_mode: '-').result(binding)
|
|
162
|
+
File.write(dest_path, content)
|
|
163
|
+
puts " create #{dest_path}"
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
137
167
|
def generate_environments
|
|
138
168
|
return if @environments.empty?
|
|
139
169
|
|
|
140
170
|
Dir.chdir(@app_name) do
|
|
141
171
|
@environments.each do |env_name|
|
|
142
|
-
Belt::CLI::EnvironmentCommand.new(env_name, quiet: true).generate
|
|
172
|
+
Belt::CLI::EnvironmentCommand.new(env_name, quiet: true, domain: @domain).generate
|
|
143
173
|
end
|
|
144
174
|
end
|
|
145
175
|
end
|
|
@@ -148,28 +178,11 @@ module Belt
|
|
|
148
178
|
return if @environments.empty?
|
|
149
179
|
|
|
150
180
|
Dir.chdir(@app_name) do
|
|
151
|
-
#
|
|
152
|
-
|
|
153
|
-
@resolved_bucket = @bucket
|
|
154
|
-
elsif aws_configured?
|
|
155
|
-
region = detect_region
|
|
156
|
-
@resolved_bucket = s3_safe_name("#{@app_name}-terraform-state-#{@aws_account_id}-#{region}")
|
|
157
|
-
else
|
|
158
|
-
@resolved_bucket = s3_safe_name("#{@app_name}-terraform-state")
|
|
159
|
-
end
|
|
160
|
-
|
|
161
|
-
# Update backend.tf files with the resolved bucket name
|
|
162
|
-
@environments.each do |env_name|
|
|
163
|
-
backend_file = "infrastructure/#{env_name}/backend.tf"
|
|
164
|
-
next unless File.exist?(backend_file)
|
|
165
|
-
|
|
166
|
-
content = File.read(backend_file)
|
|
167
|
-
updated = content.gsub(/bucket\s*=\s*"[^"]+"/, "bucket = \"#{@resolved_bucket}\"")
|
|
168
|
-
File.write(backend_file, updated) if updated != content
|
|
169
|
-
end
|
|
181
|
+
# Shared bucket: one per AWS account, all belt apps share it
|
|
182
|
+
@resolved_bucket = @bucket || 'belt-terraform-state'
|
|
170
183
|
|
|
171
184
|
# Attempt to actually create the bucket if credentials are available
|
|
172
|
-
if
|
|
185
|
+
if aws_configured?
|
|
173
186
|
puts "\n Setting up Terraform state bucket..."
|
|
174
187
|
begin
|
|
175
188
|
Belt::CLI::SetupCommand.new(["--bucket", @resolved_bucket]).run_state_setup
|
|
@@ -180,6 +193,7 @@ module Belt
|
|
|
180
193
|
end
|
|
181
194
|
else
|
|
182
195
|
puts "\n State bucket: #{@resolved_bucket}"
|
|
196
|
+
puts " State keys: #{s3_safe_name(@app_name)}/<env>/terraform.tfstate"
|
|
183
197
|
if @aws_error&.include?('ForbiddenException') || @aws_error&.include?('AccessDenied')
|
|
184
198
|
puts " ⚠ AWS credentials found but access denied — check your profile/role configuration."
|
|
185
199
|
puts " #{@aws_error}" if @aws_error
|
|
@@ -192,14 +206,6 @@ module Belt
|
|
|
192
206
|
end
|
|
193
207
|
end
|
|
194
208
|
|
|
195
|
-
def detect_region
|
|
196
|
-
Dir.glob('infrastructure/*/backend.tf').each do |f|
|
|
197
|
-
match = File.read(f).match(/region\s*=\s*"([^"]+)"/)
|
|
198
|
-
return match[1] if match
|
|
199
|
-
end
|
|
200
|
-
'us-east-1'
|
|
201
|
-
end
|
|
202
|
-
|
|
203
209
|
def aws_configured?
|
|
204
210
|
output, status = Open3.capture2e('aws', 'sts', 'get-caller-identity')
|
|
205
211
|
if status.success?
|
|
@@ -251,13 +257,56 @@ module Belt
|
|
|
251
257
|
|
|
252
258
|
def print_next_steps
|
|
253
259
|
puts "\nNext steps:"
|
|
254
|
-
puts " cd #{@app_name}"
|
|
255
260
|
unless @state_setup_succeeded
|
|
256
261
|
puts ' # Configure AWS credentials (aws sso login / AWS_PROFILE)'
|
|
257
262
|
puts ' belt setup state # Create the S3 state bucket'
|
|
258
263
|
end
|
|
259
264
|
puts ' belt deploy # Deploy to AWS'
|
|
260
265
|
puts ' belt server # Start local frontend server' if @frontend
|
|
266
|
+
if @domain
|
|
267
|
+
puts "\n Custom domain: #{@domain}"
|
|
268
|
+
puts " prod → #{@domain}"
|
|
269
|
+
puts " dev → dev.#{@domain}"
|
|
270
|
+
puts ''
|
|
271
|
+
puts ' DNS setup (after first deploy):'
|
|
272
|
+
puts ' ─────────────────────────────────────────────────────────────────'
|
|
273
|
+
puts ' 1. Run: terraform output name_servers'
|
|
274
|
+
puts ' This prints the 4 NS records for your Route53 hosted zone.'
|
|
275
|
+
puts ''
|
|
276
|
+
puts ' 2. Point your domain to those nameservers:'
|
|
277
|
+
puts ''
|
|
278
|
+
puts ' • Domain registered OUTSIDE AWS (GoDaddy, Namecheap, etc.):'
|
|
279
|
+
puts ' Go to your registrar → DNS/Nameserver settings → replace the'
|
|
280
|
+
puts ' default nameservers with the 4 values from step 1.'
|
|
281
|
+
puts ''
|
|
282
|
+
puts ' • Domain registered IN AWS (Route53 Registered Domains):'
|
|
283
|
+
puts ' Go to Route53 → Registered Domains → your domain → Name servers'
|
|
284
|
+
puts ' → Edit → paste the 4 values from step 1.'
|
|
285
|
+
puts ''
|
|
286
|
+
puts ' 3. Wait for propagation (usually 5–30 min, can take up to 48h).'
|
|
287
|
+
puts ' Verify: dig +short NS #{@domain}'
|
|
288
|
+
puts ' ─────────────────────────────────────────────────────────────────'
|
|
289
|
+
else
|
|
290
|
+
puts "\n To add a custom domain later, set `domain` in infrastructure/<env>/terraform.tfvars:"
|
|
291
|
+
puts ' domain = "myapp.com"'
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def enter_project!
|
|
296
|
+
project_path = File.expand_path(@app_name)
|
|
297
|
+
|
|
298
|
+
# Don't exec into a subshell if:
|
|
299
|
+
# - Not running interactively (CI, scripts, piped)
|
|
300
|
+
# - User explicitly opted out
|
|
301
|
+
unless $stdin.tty? && ENV['BELT_NO_CD'].nil?
|
|
302
|
+
puts "\n cd #{@app_name}"
|
|
303
|
+
return
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
Dir.chdir(project_path)
|
|
307
|
+
shell = ENV['SHELL'] || '/bin/bash'
|
|
308
|
+
puts "\n Entering #{@app_name}/...\n\n"
|
|
309
|
+
exec(shell)
|
|
261
310
|
end
|
|
262
311
|
|
|
263
312
|
def s3_safe_name(name)
|
|
@@ -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
|
-
|
|
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
|
|
59
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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}"
|
|
@@ -61,7 +61,7 @@ module Belt
|
|
|
61
61
|
exit 1
|
|
62
62
|
end
|
|
63
63
|
|
|
64
|
-
#
|
|
64
|
+
# Resolve final bucket name now that we have credentials
|
|
65
65
|
@bucket_name = resolve_bucket_name unless @custom_bucket
|
|
66
66
|
|
|
67
67
|
@bucket_name = interactive_bucket_selection if @select_mode
|
|
@@ -142,13 +142,7 @@ module Belt
|
|
|
142
142
|
if @custom_bucket
|
|
143
143
|
@custom_bucket
|
|
144
144
|
else
|
|
145
|
-
|
|
146
|
-
"#{@app_name}-terraform-state-#{@env_name}"
|
|
147
|
-
else
|
|
148
|
-
"#{@app_name}-terraform-state"
|
|
149
|
-
end
|
|
150
|
-
base = "#{base}-#{@aws_account_id}-#{@region}" if @aws_account_id
|
|
151
|
-
s3_safe_name(base)
|
|
145
|
+
'belt-terraform-state'
|
|
152
146
|
end
|
|
153
147
|
end
|
|
154
148
|
|
|
@@ -9,6 +9,7 @@ module Belt
|
|
|
9
9
|
module CLI
|
|
10
10
|
class TablesCommand
|
|
11
11
|
SCHEMA_FILE = 'infrastructure/schema.tf.rb'
|
|
12
|
+
MODULE_DIR = 'infrastructure/modules/app'
|
|
12
13
|
|
|
13
14
|
include AppDetection
|
|
14
15
|
|
|
@@ -16,37 +17,32 @@ module Belt
|
|
|
16
17
|
env = EnvResolver.resolve(args)
|
|
17
18
|
|
|
18
19
|
if env.nil?
|
|
19
|
-
puts 'Usage: belt setup tables
|
|
20
|
-
puts "\nReads schema.tf.rb and generates dynamodb.tf in the
|
|
20
|
+
puts 'Usage: belt setup tables [environment]'
|
|
21
|
+
puts "\nReads schema.tf.rb and generates dynamodb.tf in the app module."
|
|
21
22
|
puts 'You can also set BELT_ENV to skip the environment argument.'
|
|
22
23
|
puts "\nExamples:"
|
|
23
|
-
puts ' belt setup tables
|
|
24
|
-
puts ' belt setup tables
|
|
25
|
-
puts ' BELT_ENV=
|
|
24
|
+
puts ' belt setup tables'
|
|
25
|
+
puts ' belt setup tables dev'
|
|
26
|
+
puts ' BELT_ENV=dev belt setup tables'
|
|
26
27
|
exit 1
|
|
27
28
|
end
|
|
28
29
|
|
|
29
30
|
new(env).run
|
|
30
31
|
end
|
|
31
32
|
|
|
32
|
-
# Automatically sync dynamodb.tf
|
|
33
|
+
# Automatically sync dynamodb.tf in the app module.
|
|
33
34
|
# Called by generators after updating schema.tf.rb.
|
|
34
35
|
def self.sync_all_environments
|
|
35
36
|
return unless File.exist?(SCHEMA_FILE)
|
|
36
37
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
environments.each do |env|
|
|
41
|
-
new(env, quiet: true).run
|
|
42
|
-
end
|
|
38
|
+
# With the module approach, we only need to generate once into modules/app/
|
|
39
|
+
new(nil, quiet: true).run
|
|
43
40
|
end
|
|
44
41
|
|
|
45
42
|
def initialize(env, quiet: false)
|
|
46
43
|
@env = env
|
|
47
44
|
@quiet = quiet
|
|
48
45
|
@app_name = detect_app_name
|
|
49
|
-
@env_dir = "infrastructure/#{@env}"
|
|
50
46
|
end
|
|
51
47
|
|
|
52
48
|
def run
|
|
@@ -67,11 +63,11 @@ module Belt
|
|
|
67
63
|
abort "Error: #{SCHEMA_FILE} not found. Run `belt generate resource` first." unless @quiet
|
|
68
64
|
return
|
|
69
65
|
end
|
|
70
|
-
return if Dir.exist?(
|
|
66
|
+
return if Dir.exist?(MODULE_DIR)
|
|
71
67
|
|
|
72
68
|
unless @quiet
|
|
73
|
-
abort "Error:
|
|
74
|
-
"
|
|
69
|
+
abort "Error: Module directory not found at #{MODULE_DIR}/.\n" \
|
|
70
|
+
"Run `belt new` to create a project with the correct structure."
|
|
75
71
|
end
|
|
76
72
|
end
|
|
77
73
|
|
|
@@ -82,7 +78,6 @@ module Belt
|
|
|
82
78
|
schema_content = File.read(SCHEMA_FILE)
|
|
83
79
|
|
|
84
80
|
# Replace DSL wrapper with direct parser call
|
|
85
|
-
# Belt.application.schema.draw do ... end → parser.instance_eval do ... end
|
|
86
81
|
inner = schema_content.sub(/\A(?:Belt\.application)\.schema\.draw do\n?/, '').sub(/\n?end\s*\z/, '')
|
|
87
82
|
inner.strip!
|
|
88
83
|
|
|
@@ -93,7 +88,7 @@ module Belt
|
|
|
93
88
|
end
|
|
94
89
|
|
|
95
90
|
def generate_dynamodb_tf(models)
|
|
96
|
-
dest = File.join(
|
|
91
|
+
dest = File.join(MODULE_DIR, 'dynamodb.tf')
|
|
97
92
|
existing_content = File.exist?(dest) ? File.read(dest) : nil
|
|
98
93
|
new_content = render_dynamodb(models)
|
|
99
94
|
|
|
@@ -108,7 +103,7 @@ module Belt
|
|
|
108
103
|
else
|
|
109
104
|
puts " create #{dest}"
|
|
110
105
|
puts "\n✓ Generated DynamoDB tables for #{models.size} model(s):"
|
|
111
|
-
models.each { |m| puts " • #{
|
|
106
|
+
models.each { |m| puts " • #{Belt::Inflector.pluralize(m[:name])}" }
|
|
112
107
|
puts "\nRun `belt deploy` to create them."
|
|
113
108
|
end
|
|
114
109
|
end
|
|
@@ -116,7 +111,7 @@ module Belt
|
|
|
116
111
|
def render_dynamodb(models)
|
|
117
112
|
blocks = models.map { |m| render_table(m) }
|
|
118
113
|
"# Auto-generated by Belt from schema.tf.rb\n" \
|
|
119
|
-
"# Do not edit manually — re-run `belt setup tables
|
|
114
|
+
"# Do not edit manually — re-run `belt setup tables`\n\n#{blocks.join("\n\n")}\n"
|
|
120
115
|
end
|
|
121
116
|
|
|
122
117
|
def render_table(model)
|
|
@@ -142,7 +137,7 @@ module Belt
|
|
|
142
137
|
end
|
|
143
138
|
|
|
144
139
|
def table_name(model_name)
|
|
145
|
-
"#{@app_name}
|
|
140
|
+
"#{@app_name}-${var.environment}-#{Belt::Inflector.pluralize(model_name)}"
|
|
146
141
|
end
|
|
147
142
|
|
|
148
143
|
# Minimal DSL parser for schema.tf.rb
|