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.
@@ -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)
@@ -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
- Automatically sets VITE_API_URL from terraform outputs if deployed.
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 automatically points to
59
- your deployed API via VITE_API_URL.
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
- if @api_url
88
- puts " Backend API: #{@api_url}"
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
- # Re-resolve bucket name with account ID suffix now that we have credentials
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
- base = if @env_name
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 <environment>'
20
- puts "\nReads schema.tf.rb and generates dynamodb.tf in the environment directory."
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 wups'
24
- puts ' belt setup tables dev01'
25
- puts ' BELT_ENV=wups belt setup tables'
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 for all existing environments.
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
- environments = TerraformCommand.list_environments
38
- return if environments.empty?
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?(@env_dir)
66
+ return if Dir.exist?(MODULE_DIR)
71
67
 
72
68
  unless @quiet
73
- abort "Error: Environment '#{@env}' not found at #{@env_dir}/.\n" \
74
- "Create it with: belt generate environment #{@env}"
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(@env_dir, 'dynamodb.tf')
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 " • #{table_name(m[:name])}" }
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 #{@env}`\n\n#{blocks.join("\n\n")}\n"
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}-#{@env}-#{Belt::Inflector.pluralize(model_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