belt 0.1.8 → 0.1.9
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/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_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/setup_command.rb +2 -8
- data/lib/belt/cli/tables_command.rb +16 -21
- 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/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 +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c3c75f106f7a0f2ebf672294ee30e96f96745fb3ecbd142307c08b6b006933c8
|
|
4
|
+
data.tar.gz: 470cefb769cfab7dbaf80e6f950eb62cf87fb5a6b297f3661cf147c26d494501
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e5c5ed3dfa729483fd54e28197c9bd2f47f3581bea5f0892a4ac44c02f55253809f4fe1c12167ca55a040bdebc97e545b462f08d2f42c23dd1304a4a9dfd7768
|
|
7
|
+
data.tar.gz: be36355b315e467f411079880a4b3e14e94b56567cbf1892a95fff3f2b3c1c9a8bfe91e2e8618b405b1b06045ee1c06e999c12a6c281b9c490b8e8dc43d8f999
|
|
@@ -503,8 +503,48 @@ module Belt
|
|
|
503
503
|
output.each_line do |line|
|
|
504
504
|
puts " #{line}"
|
|
505
505
|
end
|
|
506
|
+
|
|
507
|
+
# Show DNS guidance if name_servers are present and this looks like a first deploy
|
|
508
|
+
print_dns_guidance if output.include?('name_servers')
|
|
506
509
|
end
|
|
507
510
|
end
|
|
511
|
+
|
|
512
|
+
def print_dns_guidance
|
|
513
|
+
# Check if there's a marker file indicating DNS guidance was already shown
|
|
514
|
+
marker = File.join(@infra_dir, @env, '.dns_configured')
|
|
515
|
+
return if File.exist?(marker)
|
|
516
|
+
|
|
517
|
+
domain = detect_domain_from_tfvars
|
|
518
|
+
return unless domain
|
|
519
|
+
|
|
520
|
+
puts ''
|
|
521
|
+
puts ' ┌─────────────────────────────────────────────────────────────┐'
|
|
522
|
+
puts ' │ DNS SETUP REQUIRED │'
|
|
523
|
+
puts ' ├─────────────────────────────────────────────────────────────┤'
|
|
524
|
+
puts ' │ Point your domain to the name_servers shown above. │'
|
|
525
|
+
puts ' │ │'
|
|
526
|
+
puts ' │ • External registrar (GoDaddy, Namecheap, etc.): │'
|
|
527
|
+
puts ' │ Update nameservers in your registrar\'s DNS settings. │'
|
|
528
|
+
puts ' │ │'
|
|
529
|
+
puts ' │ • Domain in AWS Route53 Registered Domains: │'
|
|
530
|
+
puts ' │ Route53 → Registered Domains → Name servers → Edit. │'
|
|
531
|
+
puts ' │ │'
|
|
532
|
+
puts " │ Verify: dig +short NS #{domain.ljust(38)}│"
|
|
533
|
+
puts ' │ │'
|
|
534
|
+
puts ' │ This message won\'t appear on subsequent deploys. │'
|
|
535
|
+
puts ' └─────────────────────────────────────────────────────────────┘'
|
|
536
|
+
|
|
537
|
+
# Create the marker so we don't nag on every deploy
|
|
538
|
+
File.write(marker, "DNS guidance shown at #{Time.now}\n")
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
def detect_domain_from_tfvars
|
|
542
|
+
tfvars_file = File.join(@infra_dir, @env, 'terraform.tfvars')
|
|
543
|
+
return nil unless File.exist?(tfvars_file)
|
|
544
|
+
|
|
545
|
+
match = File.read(tfvars_file).match(/^\s*domain\s*=\s*"([^"]+)"/)
|
|
546
|
+
match[1] if match
|
|
547
|
+
end
|
|
508
548
|
end
|
|
509
549
|
end
|
|
510
550
|
end
|
|
@@ -121,8 +121,6 @@ module Belt
|
|
|
121
121
|
destroy_model
|
|
122
122
|
destroy_controller
|
|
123
123
|
remove_routes
|
|
124
|
-
remove_schema
|
|
125
|
-
sync_tables
|
|
126
124
|
destroy_views
|
|
127
125
|
puts "\n✓ Scaffold '#{@singular_name}' destroyed!"
|
|
128
126
|
end
|
|
@@ -130,6 +128,8 @@ module Belt
|
|
|
130
128
|
def destroy_model
|
|
131
129
|
path = "lambda/models/#{@singular_name}.rb"
|
|
132
130
|
remove_file(path)
|
|
131
|
+
remove_schema
|
|
132
|
+
sync_tables
|
|
133
133
|
end
|
|
134
134
|
|
|
135
135
|
def destroy_controller
|
|
@@ -157,14 +157,12 @@ module Belt
|
|
|
157
157
|
@removed << dir
|
|
158
158
|
puts " remove #{dir}/"
|
|
159
159
|
|
|
160
|
-
# Remove frontend.tf
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
main_tf = File.join(env_dir, 'main.tf')
|
|
167
|
-
revert_main_tf_frontend_urls(main_tf)
|
|
160
|
+
# Remove frontend.tf from the module
|
|
161
|
+
module_frontend = 'infrastructure/modules/app/frontend.tf'
|
|
162
|
+
if File.exist?(module_frontend)
|
|
163
|
+
FileUtils.rm_f(module_frontend)
|
|
164
|
+
@removed << module_frontend
|
|
165
|
+
puts " remove #{module_frontend}"
|
|
168
166
|
end
|
|
169
167
|
|
|
170
168
|
puts "\n✓ Frontend removed!"
|
|
@@ -174,24 +172,6 @@ module Belt
|
|
|
174
172
|
end
|
|
175
173
|
end
|
|
176
174
|
|
|
177
|
-
def revert_main_tf_frontend_urls(main_tf)
|
|
178
|
-
return unless File.exist?(main_tf)
|
|
179
|
-
|
|
180
|
-
content = File.read(main_tf)
|
|
181
|
-
return unless content.include?('aws_cloudfront_distribution.frontend.domain_name')
|
|
182
|
-
|
|
183
|
-
# Replace the multi-line concat block back to a simple conditional
|
|
184
|
-
replaced = content.sub(
|
|
185
|
-
/^\s*frontend_urls\s*=\s*concat\(\n(?:.*\n)*?\s*\)\s*\n/,
|
|
186
|
-
" frontend_urls = var.environment == \"prod\" ? [] : [\"http://localhost:3000\"]\n"
|
|
187
|
-
)
|
|
188
|
-
|
|
189
|
-
if replaced != content
|
|
190
|
-
File.write(main_tf, replaced)
|
|
191
|
-
puts " update #{main_tf} (reverted frontend_urls)"
|
|
192
|
-
end
|
|
193
|
-
end
|
|
194
|
-
|
|
195
175
|
def destroy_views
|
|
196
176
|
return unless @resource_name
|
|
197
177
|
|
|
@@ -26,9 +26,10 @@ module Belt
|
|
|
26
26
|
new(env_name).generate
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
-
def initialize(env_name, quiet: false)
|
|
29
|
+
def initialize(env_name, quiet: false, domain: nil)
|
|
30
30
|
@env_name = env_name.downcase.gsub(/[^a-z0-9_-]/, '')
|
|
31
31
|
@app_name = detect_app_name
|
|
32
|
+
@domain = domain
|
|
32
33
|
@quiet = quiet
|
|
33
34
|
end
|
|
34
35
|
|
|
@@ -51,28 +52,19 @@ module Belt
|
|
|
51
52
|
|
|
52
53
|
puts "\n✓ Environment '#{@env_name}' created!"
|
|
53
54
|
|
|
54
|
-
setup_frontend_if_exists
|
|
55
|
-
|
|
56
55
|
unless @quiet
|
|
57
56
|
puts "\nNext steps:"
|
|
58
57
|
puts " cd #{dest_dir}"
|
|
59
58
|
puts ' terraform init'
|
|
60
59
|
puts ' terraform plan'
|
|
61
60
|
puts ' terraform apply'
|
|
61
|
+
puts "\nTo configure a custom domain, set `domain` in #{dest_dir}/terraform.tfvars:"
|
|
62
|
+
puts " domain = \"myapp.com\""
|
|
62
63
|
end
|
|
63
64
|
end
|
|
64
65
|
|
|
65
66
|
private
|
|
66
67
|
|
|
67
|
-
def setup_frontend_if_exists
|
|
68
|
-
return unless Dir.exist?('frontend') && !Dir.empty?('frontend')
|
|
69
|
-
|
|
70
|
-
puts "\n Frontend detected — generating frontend infrastructure..."
|
|
71
|
-
require_relative 'frontend_setup_command'
|
|
72
|
-
FrontendSetupCommand.new(@env_name, quiet: true).run
|
|
73
|
-
puts " create infrastructure/#{@env_name}/frontend.tf"
|
|
74
|
-
end
|
|
75
|
-
|
|
76
68
|
def templates
|
|
77
69
|
{
|
|
78
70
|
'main.tf.erb' => 'main.tf',
|
|
@@ -75,22 +75,20 @@ module Belt
|
|
|
75
75
|
end
|
|
76
76
|
|
|
77
77
|
def setup_frontend_infra_for_existing_environments
|
|
78
|
-
|
|
79
|
-
|
|
78
|
+
module_dir = 'infrastructure/modules/app'
|
|
79
|
+
frontend_tf = File.join(module_dir, 'frontend.tf')
|
|
80
80
|
|
|
81
|
-
|
|
82
|
-
|
|
81
|
+
if File.exist?(frontend_tf)
|
|
82
|
+
puts " skip #{frontend_tf} (already exists)"
|
|
83
|
+
return
|
|
84
|
+
end
|
|
83
85
|
|
|
84
|
-
|
|
85
|
-
frontend_tf = "infrastructure/#{env_name}/frontend.tf"
|
|
86
|
-
if File.exist?(frontend_tf)
|
|
87
|
-
puts " skip #{frontend_tf} (already exists)"
|
|
88
|
-
next
|
|
89
|
-
end
|
|
86
|
+
return unless Dir.exist?(module_dir)
|
|
90
87
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
88
|
+
puts "\n Setting up frontend infrastructure..."
|
|
89
|
+
require_relative 'frontend_setup_command'
|
|
90
|
+
FrontendSetupCommand.new(nil, quiet: true).run
|
|
91
|
+
puts " create #{frontend_tf}"
|
|
94
92
|
end
|
|
95
93
|
|
|
96
94
|
def copy_template(src_dir, dest_dir)
|
|
@@ -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)
|