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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ebbefe3f44abda51ac10b219cda18678ca834d06d8eab39e31beb3e48e99b25a
4
- data.tar.gz: 3233b91c7297982ef7f5037331ef003c3637ebc6dccf4321b4a5f758ed57f1d9
3
+ metadata.gz: c3c75f106f7a0f2ebf672294ee30e96f96745fb3ecbd142307c08b6b006933c8
4
+ data.tar.gz: 470cefb769cfab7dbaf80e6f950eb62cf87fb5a6b297f3661cf147c26d494501
5
5
  SHA512:
6
- metadata.gz: af38bf5330f69577f37ab34a0bd7510b5d84aa14969efd8975ac6875ea85450aaa741beeb71bf3f51f627af11c0c5445806081156706cdfd4901fba4301e33ed
7
- data.tar.gz: 2eade1680ebfe76e78ab38e38d273565cf34eb1f1789427d358706ac2c4c128a5f86448a5eb2a146161be8c912bf53f9229fceb0904719c9df5ef3444dc3481e
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 and revert frontend_urls in main.tf for each environment
161
- Dir.glob('infrastructure/*/frontend.tf').each do |frontend_tf|
162
- FileUtils.rm_f(frontend_tf)
163
- puts " remove #{frontend_tf}"
164
-
165
- env_dir = File.dirname(frontend_tf)
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
- environments = detect_environments
79
- return if environments.empty?
78
+ module_dir = 'infrastructure/modules/app'
79
+ frontend_tf = File.join(module_dir, 'frontend.tf')
80
80
 
81
- puts "\n Setting up frontend infrastructure for existing environments..."
82
- require_relative 'frontend_setup_command'
81
+ if File.exist?(frontend_tf)
82
+ puts " skip #{frontend_tf} (already exists)"
83
+ return
84
+ end
83
85
 
84
- environments.each do |env_name|
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
- FrontendSetupCommand.new(env_name, quiet: true).run
92
- puts " create #{frontend_tf}"
93
- end
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/frontend_infra', __dir__)
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
- env = EnvResolver.resolve(args)
17
-
18
- if env.nil?
19
- puts 'Usage: belt setup frontend <environment>'
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 for '#{@env}'!"
46
- puts "\nRun `belt apply #{@env}` to create the S3 bucket and CloudFront distribution."
47
- puts "Then `belt deploy frontend #{@env}` to build and deploy."
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?(@env_dir)
41
+ return if Dir.exist?(MODULE_DIR)
54
42
 
55
- abort "Error: Environment '#{@env}' not found at #{@env_dir}/.\n" \
56
- "Create it with: belt generate environment #{@env}"
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(@env_dir, 'frontend.tf')
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' then generate_resource
232
- when 'model' then generate_model_standalone
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
- if content.include?('# resources :posts')
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
- if content.match?(/^\s*#\s*model :/)
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
@@ -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
- # Resolve bucket name include account ID + region if AWS credentials are available
152
- if @bucket
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 @aws_account_id
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)