belt 0.1.7 → 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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/lib/belt/cli/deploy_command.rb +40 -0
  3. data/lib/belt/cli/destroy_command.rb +21 -8
  4. data/lib/belt/cli/environment_command.rb +4 -12
  5. data/lib/belt/cli/frontend_command.rb +11 -13
  6. data/lib/belt/cli/frontend_setup_command.rb +14 -25
  7. data/lib/belt/cli/generate_command.rb +106 -32
  8. data/lib/belt/cli/new_command.rb +82 -35
  9. data/lib/belt/cli/routes_command.rb +1 -9
  10. data/lib/belt/cli/setup_command.rb +2 -8
  11. data/lib/belt/cli/tables_command.rb +58 -25
  12. data/lib/belt/cli/views_command.rb +11 -8
  13. data/lib/belt/inflector.rb +58 -0
  14. data/lib/belt/lambda_handler.rb +11 -0
  15. data/lib/belt/route_dsl.rb +3 -11
  16. data/lib/belt/table_inference.rb +4 -18
  17. data/lib/belt/version.rb +1 -1
  18. data/lib/templates/environment/backend.tf.erb +2 -2
  19. data/lib/templates/environment/main.tf.erb +14 -18
  20. data/lib/templates/environment/outputs.tf.erb +28 -3
  21. data/lib/templates/environment/terraform.tfvars.erb +5 -0
  22. data/lib/templates/environment/variables.tf.erb +6 -0
  23. data/lib/templates/generate/controller.rb.erb +0 -1
  24. data/lib/templates/generate/model.rb.erb +0 -2
  25. data/lib/templates/module/dns.tf.erb +83 -0
  26. data/lib/templates/module/frontend.tf.erb +179 -0
  27. data/lib/templates/module/main.tf.erb +42 -0
  28. data/lib/templates/module/outputs.tf.erb +24 -0
  29. data/lib/templates/module/variables.tf.erb +27 -0
  30. data/lib/templates/new_app/lambda/config/environment.rb.erb +7 -1
  31. data/lib/templates/views/Form.jsx.erb +63 -7
  32. data/lib/templates/views/Index.jsx.erb +41 -10
  33. data/lib/templates/views/Show.jsx.erb +12 -2
  34. metadata +21 -2
  35. data/lib/templates/new_app/lambda/models/concerns/timestampable.rb.erb +0 -23
@@ -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
@@ -109,11 +119,10 @@ module Belt
109
119
  %W[
110
120
  #{@app_name}/lambda/controllers/api
111
121
  #{@app_name}/lambda/models
112
- #{@app_name}/lambda/models/concerns
113
122
  #{@app_name}/lambda/lib/routes
114
123
  #{@app_name}/lambda/config
115
124
  #{@app_name}/lambda/spec
116
- #{@app_name}/infrastructure
125
+ #{@app_name}/infrastructure/modules/app
117
126
  ]
118
127
  end
119
128
 
@@ -124,7 +133,6 @@ module Belt
124
133
  'lambda/api.rb.erb' => "#{@app_name}/lambda/api.rb",
125
134
  'lambda/config/environment.rb.erb' => "#{@app_name}/lambda/config/environment.rb",
126
135
  'lambda/models/application_record.rb.erb' => "#{@app_name}/lambda/models/application_record.rb",
127
- 'lambda/models/concerns/timestampable.rb.erb' => "#{@app_name}/lambda/models/concerns/timestampable.rb",
128
136
  'lambda/controllers/application_controller.rb.erb' =>
129
137
  "#{@app_name}/lambda/controllers/api/application_controller.rb",
130
138
  'lambda/lib/routes/routes.rb.erb' => "#{@app_name}/lambda/lib/routes/api_routes.rb",
@@ -136,12 +144,32 @@ module Belt
136
144
  }
137
145
  end
138
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
+
139
167
  def generate_environments
140
168
  return if @environments.empty?
141
169
 
142
170
  Dir.chdir(@app_name) do
143
171
  @environments.each do |env_name|
144
- Belt::CLI::EnvironmentCommand.new(env_name, quiet: true).generate
172
+ Belt::CLI::EnvironmentCommand.new(env_name, quiet: true, domain: @domain).generate
145
173
  end
146
174
  end
147
175
  end
@@ -150,28 +178,11 @@ module Belt
150
178
  return if @environments.empty?
151
179
 
152
180
  Dir.chdir(@app_name) do
153
- # Resolve bucket name include account ID + region if AWS credentials are available
154
- if @bucket
155
- @resolved_bucket = @bucket
156
- elsif aws_configured?
157
- region = detect_region
158
- @resolved_bucket = s3_safe_name("#{@app_name}-terraform-state-#{@aws_account_id}-#{region}")
159
- else
160
- @resolved_bucket = s3_safe_name("#{@app_name}-terraform-state")
161
- end
162
-
163
- # Update backend.tf files with the resolved bucket name
164
- @environments.each do |env_name|
165
- backend_file = "infrastructure/#{env_name}/backend.tf"
166
- next unless File.exist?(backend_file)
167
-
168
- content = File.read(backend_file)
169
- updated = content.gsub(/bucket\s*=\s*"[^"]+"/, "bucket = \"#{@resolved_bucket}\"")
170
- File.write(backend_file, updated) if updated != content
171
- end
181
+ # Shared bucket: one per AWS account, all belt apps share it
182
+ @resolved_bucket = @bucket || 'belt-terraform-state'
172
183
 
173
184
  # Attempt to actually create the bucket if credentials are available
174
- if @aws_account_id
185
+ if aws_configured?
175
186
  puts "\n Setting up Terraform state bucket..."
176
187
  begin
177
188
  Belt::CLI::SetupCommand.new(["--bucket", @resolved_bucket]).run_state_setup
@@ -182,6 +193,7 @@ module Belt
182
193
  end
183
194
  else
184
195
  puts "\n State bucket: #{@resolved_bucket}"
196
+ puts " State keys: #{s3_safe_name(@app_name)}/<env>/terraform.tfstate"
185
197
  if @aws_error&.include?('ForbiddenException') || @aws_error&.include?('AccessDenied')
186
198
  puts " ⚠ AWS credentials found but access denied — check your profile/role configuration."
187
199
  puts " #{@aws_error}" if @aws_error
@@ -194,14 +206,6 @@ module Belt
194
206
  end
195
207
  end
196
208
 
197
- def detect_region
198
- Dir.glob('infrastructure/*/backend.tf').each do |f|
199
- match = File.read(f).match(/region\s*=\s*"([^"]+)"/)
200
- return match[1] if match
201
- end
202
- 'us-east-1'
203
- end
204
-
205
209
  def aws_configured?
206
210
  output, status = Open3.capture2e('aws', 'sts', 'get-caller-identity')
207
211
  if status.success?
@@ -253,13 +257,56 @@ module Belt
253
257
 
254
258
  def print_next_steps
255
259
  puts "\nNext steps:"
256
- puts " cd #{@app_name}"
257
260
  unless @state_setup_succeeded
258
261
  puts ' # Configure AWS credentials (aws sso login / AWS_PROFILE)'
259
262
  puts ' belt setup state # Create the S3 state bucket'
260
263
  end
261
264
  puts ' belt deploy # Deploy to AWS'
262
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)
263
310
  end
264
311
 
265
312
  def s3_safe_name(name)
@@ -292,15 +292,7 @@ module Belt
292
292
  end
293
293
 
294
294
  def singularize(word)
295
- if word.end_with?('ies')
296
- "#{word[0..-4]}y"
297
- elsif word.end_with?('ses') || word.end_with?('xes') || word.end_with?('zes')
298
- word[0..-3]
299
- elsif word.end_with?('s') && !word.end_with?('ss')
300
- word[0..-2]
301
- else
302
- word
303
- end
295
+ Belt::Inflector.singularize(word)
304
296
  end
305
297
  end
306
298
  end
@@ -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
 
@@ -2,11 +2,14 @@
2
2
 
3
3
  require_relative 'app_detection'
4
4
  require_relative 'env_resolver'
5
+ require_relative 'terraform_command'
6
+ require_relative '../inflector'
5
7
 
6
8
  module Belt
7
9
  module CLI
8
10
  class TablesCommand
9
11
  SCHEMA_FILE = 'infrastructure/schema.tf.rb'
12
+ MODULE_DIR = 'infrastructure/modules/app'
10
13
 
11
14
  include AppDetection
12
15
 
@@ -14,31 +17,40 @@ module Belt
14
17
  env = EnvResolver.resolve(args)
15
18
 
16
19
  if env.nil?
17
- puts 'Usage: belt setup tables <environment>'
18
- 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."
19
22
  puts 'You can also set BELT_ENV to skip the environment argument.'
20
23
  puts "\nExamples:"
21
- puts ' belt setup tables wups'
22
- puts ' belt setup tables dev01'
23
- 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'
24
27
  exit 1
25
28
  end
26
29
 
27
30
  new(env).run
28
31
  end
29
32
 
30
- def initialize(env)
33
+ # Automatically sync dynamodb.tf in the app module.
34
+ # Called by generators after updating schema.tf.rb.
35
+ def self.sync_all_environments
36
+ return unless File.exist?(SCHEMA_FILE)
37
+
38
+ # With the module approach, we only need to generate once into modules/app/
39
+ new(nil, quiet: true).run
40
+ end
41
+
42
+ def initialize(env, quiet: false)
31
43
  @env = env
44
+ @quiet = quiet
32
45
  @app_name = detect_app_name
33
- @env_dir = "infrastructure/#{@env}"
34
46
  end
35
47
 
36
48
  def run
37
49
  validate!
38
50
  models = parse_schema
39
51
  if models.empty?
40
- puts "No models found in #{SCHEMA_FILE}"
41
- exit 0
52
+ puts "No models found in #{SCHEMA_FILE}" unless @quiet
53
+ return
42
54
  end
43
55
 
44
56
  generate_dynamodb_tf(models)
@@ -47,44 +59,65 @@ module Belt
47
59
  private
48
60
 
49
61
  def validate!
50
- abort "Error: #{SCHEMA_FILE} not found. Run `belt generate resource` first." unless File.exist?(SCHEMA_FILE)
51
- return if Dir.exist?(@env_dir)
62
+ unless File.exist?(SCHEMA_FILE)
63
+ abort "Error: #{SCHEMA_FILE} not found. Run `belt generate resource` first." unless @quiet
64
+ return
65
+ end
66
+ return if Dir.exist?(MODULE_DIR)
52
67
 
53
- abort "Error: Environment '#{@env}' not found at #{@env_dir}/.\n" \
54
- "Create it with: belt generate environment #{@env}"
68
+ unless @quiet
69
+ abort "Error: Module directory not found at #{MODULE_DIR}/.\n" \
70
+ "Run `belt new` to create a project with the correct structure."
71
+ end
55
72
  end
56
73
 
57
74
  def parse_schema
75
+ return [] unless File.exist?(SCHEMA_FILE)
76
+
58
77
  parser = SchemaParser.new
59
78
  schema_content = File.read(SCHEMA_FILE)
60
79
 
61
80
  # Replace DSL wrapper with direct parser call
62
- # Belt.application.schema.draw do ... end → parser.instance_eval do ... end
63
- inner = schema_content.sub(/\A(?:Belt\.application)\.schema\.draw do\n?/, '').sub(/\nend\s*\z/, '')
81
+ inner = schema_content.sub(/\A(?:Belt\.application)\.schema\.draw do\n?/, '').sub(/\n?end\s*\z/, '')
82
+ inner.strip!
83
+
84
+ return [] if inner.empty? || inner.match?(/\A\s*\z/m)
85
+
64
86
  parser.instance_eval(inner, SCHEMA_FILE)
65
87
  parser.models
66
88
  end
67
89
 
68
90
  def generate_dynamodb_tf(models)
69
- dest = File.join(@env_dir, 'dynamodb.tf')
70
- content = render_dynamodb(models)
71
- File.write(dest, content)
72
- puts " create #{dest}"
73
- puts "\n✓ Generated DynamoDB tables for #{models.size} model(s):"
74
- models.each { |m| puts " • #{table_name(m[:name])}" }
75
- puts "\nRun `belt apply #{@env}` to create them."
91
+ dest = File.join(MODULE_DIR, 'dynamodb.tf')
92
+ existing_content = File.exist?(dest) ? File.read(dest) : nil
93
+ new_content = render_dynamodb(models)
94
+
95
+ # Skip if content is unchanged
96
+ return if existing_content == new_content
97
+
98
+ File.write(dest, new_content)
99
+
100
+ if @quiet
101
+ verb = existing_content ? 'update' : 'create'
102
+ puts " #{verb} #{dest}"
103
+ else
104
+ puts " create #{dest}"
105
+ puts "\n✓ Generated DynamoDB tables for #{models.size} model(s):"
106
+ models.each { |m| puts " • #{Belt::Inflector.pluralize(m[:name])}" }
107
+ puts "\nRun `belt deploy` to create them."
108
+ end
76
109
  end
77
110
 
78
111
  def render_dynamodb(models)
79
112
  blocks = models.map { |m| render_table(m) }
80
113
  "# Auto-generated by Belt from schema.tf.rb\n" \
81
- "# 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"
82
115
  end
83
116
 
84
117
  def render_table(model)
85
118
  name = table_name(model[:name])
86
119
  <<~HCL
87
- resource "aws_dynamodb_table" "#{model[:name]}s" {
120
+ resource "aws_dynamodb_table" "#{Belt::Inflector.pluralize(model[:name])}" {
88
121
  name = "#{name}"
89
122
  billing_mode = "PAY_PER_REQUEST"
90
123
  hash_key = "id"
@@ -104,7 +137,7 @@ module Belt
104
137
  end
105
138
 
106
139
  def table_name(model_name)
107
- "#{@app_name}-#{@env}-#{model_name}s"
140
+ "#{@app_name}-${var.environment}-#{Belt::Inflector.pluralize(model_name)}"
108
141
  end
109
142
 
110
143
  # Minimal DSL parser for schema.tf.rb
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'fileutils'
4
4
  require 'erb'
5
+ require_relative '../inflector'
5
6
 
6
7
  module Belt
7
8
  module CLI
@@ -35,7 +36,7 @@ module Belt
35
36
  return [] unless File.exist?(schema_file)
36
37
 
37
38
  content = File.read(schema_file)
38
- singular = name.end_with?('s') ? name.chomp('s') : name
39
+ singular = Belt::Inflector.singularize(name)
39
40
 
40
41
  # Extract fields from model block
41
42
  if content =~ /model :#{singular} do\n(.*?)\n\s*end/m
@@ -53,9 +54,9 @@ module Belt
53
54
  def initialize(name, fields)
54
55
  @name = name.downcase.gsub(/[^a-z0-9_]/, '_')
55
56
  @fields = fields
56
- @resource_name = @name.end_with?('s') ? @name : "#{@name}s"
57
- @singular_name = @name.end_with?('s') ? @name.chomp('s') : @name
58
- @class_name = @singular_name.split('_').map(&:capitalize).join
57
+ @singular_name = Belt::Inflector.singularize(@name)
58
+ @resource_name = Belt::Inflector.pluralize(@singular_name)
59
+ @class_name = Belt::Inflector.classify(@singular_name)
59
60
  end
60
61
 
61
62
  def generate
@@ -65,9 +66,10 @@ module Belt
65
66
  end
66
67
 
67
68
  pages_dir = "frontend/src/pages/#{@resource_name}"
69
+ @plural_class_name = Belt::Inflector.camelize(@resource_name)
68
70
  FileUtils.mkdir_p(pages_dir)
69
71
 
70
- write_template('Index.jsx.erb', "#{pages_dir}/#{@class_name}sIndex.jsx")
72
+ write_template('Index.jsx.erb', "#{pages_dir}/#{@plural_class_name}Index.jsx")
71
73
  write_template('Show.jsx.erb', "#{pages_dir}/#{@class_name}Show.jsx")
72
74
  write_template('New.jsx.erb', "#{pages_dir}/#{@class_name}New.jsx")
73
75
  write_template('Edit.jsx.erb', "#{pages_dir}/#{@class_name}Edit.jsx")
@@ -77,7 +79,7 @@ module Belt
77
79
 
78
80
  puts "\n✓ Views for '#{@singular_name}' generated!"
79
81
  puts "\nFiles created:"
80
- puts " #{pages_dir}/#{@class_name}sIndex.jsx"
82
+ puts " #{pages_dir}/#{@plural_class_name}Index.jsx"
81
83
  puts " #{pages_dir}/#{@class_name}Show.jsx"
82
84
  puts " #{pages_dir}/#{@class_name}New.jsx"
83
85
  puts " #{pages_dir}/#{@class_name}Edit.jsx"
@@ -100,16 +102,17 @@ module Belt
100
102
 
101
103
  content = File.read(app_jsx)
102
104
  pages_dir = @resource_name
105
+ plural_class = @plural_class_name || Belt::Inflector.camelize(@resource_name)
103
106
 
104
107
  import_lines = [
105
- "import #{@class_name}sIndex from './pages/#{pages_dir}/#{@class_name}sIndex'",
108
+ "import #{plural_class}Index from './pages/#{pages_dir}/#{plural_class}Index'",
106
109
  "import #{@class_name}Show from './pages/#{pages_dir}/#{@class_name}Show'",
107
110
  "import #{@class_name}New from './pages/#{pages_dir}/#{@class_name}New'",
108
111
  "import #{@class_name}Edit from './pages/#{pages_dir}/#{@class_name}Edit'"
109
112
  ]
110
113
 
111
114
  route_lines = [
112
- " <Route path=\"/#{@resource_name}\" element={<#{@class_name}sIndex />} />",
115
+ " <Route path=\"/#{@resource_name}\" element={<#{plural_class}Index />} />",
113
116
  " <Route path=\"/#{@resource_name}/new\" element={<#{@class_name}New />} />",
114
117
  " <Route path=\"/#{@resource_name}/:id\" element={<#{@class_name}Show />} />",
115
118
  " <Route path=\"/#{@resource_name}/:id/edit\" element={<#{@class_name}Edit />} />"
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/string/inflections'
4
+ require 'active_support/inflector'
5
+
6
+ # Add common inflection rules that ActiveSupport doesn't include by default
7
+ ActiveSupport::Inflector.inflections(:en) do |inflect|
8
+ inflect.irregular 'hero', 'heroes'
9
+ inflect.irregular 'potato', 'potatoes'
10
+ inflect.irregular 'volcano', 'volcanoes'
11
+ inflect.irregular 'echo', 'echoes'
12
+ inflect.irregular 'embargo', 'embargoes'
13
+ inflect.irregular 'veto', 'vetoes'
14
+ end
15
+
16
+ module Belt
17
+ # Provides proper English inflection rules (pluralize, singularize, classify, etc.)
18
+ # via ActiveSupport::Inflector. Used throughout Belt for resource name derivation.
19
+ #
20
+ # ActiveSupport is already a transitive dependency (via activeitem → activemodel → activesupport)
21
+ # so this adds zero new weight.
22
+ module Inflector
23
+ module_function
24
+
25
+ # "hero" → "heroes", "post" → "posts", "person" → "people"
26
+ def pluralize(word)
27
+ word.to_s.pluralize
28
+ end
29
+
30
+ # "heroes" → "hero", "posts" → "post", "people" → "person"
31
+ def singularize(word)
32
+ word.to_s.singularize
33
+ end
34
+
35
+ # "blog_post" → "BlogPost", "hero" → "Hero"
36
+ def classify(word)
37
+ singularize(word).split('_').map(&:capitalize).join
38
+ end
39
+
40
+ # "blog_posts" → "BlogPosts", "heroes" → "Heroes" (no singularization)
41
+ def camelize(word)
42
+ word.to_s.split('_').map(&:capitalize).join
43
+ end
44
+
45
+ # "BlogPost" → "blog_post"
46
+ def underscore(word)
47
+ word.to_s
48
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
49
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
50
+ .downcase
51
+ end
52
+
53
+ # "heroes_controller" → "heroes", "PostsController" → "posts"
54
+ def resource_name(word)
55
+ pluralize(word.to_s.sub(/_?controller$/i, '').downcase)
56
+ end
57
+ end
58
+ end
@@ -41,6 +41,17 @@ module Belt
41
41
  subdir = File.join(controllers_dir, child)
42
42
  Belt.controller_paths << subdir if File.directory?(subdir)
43
43
  end
44
+
45
+ # Auto-load all models (application_record first, then the rest)
46
+ models_dir = File.join(File.dirname(caller_file), 'models')
47
+ if File.directory?(models_dir)
48
+ all_models = Dir[File.join(models_dir, '**', '*.rb')].sort
49
+ app_record = all_models.find { |f| File.basename(f) == 'application_record.rb' }
50
+ rest = all_models - [app_record].compact
51
+
52
+ require app_record if app_record
53
+ rest.each { |f| require f }
54
+ end
44
55
  end
45
56
 
46
57
  # API Gateway Lambda handler.
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'inflector'
4
+
3
5
  module Belt
4
6
  # DSL for defining API Gateway routes.
5
7
  # Ported from terraform-provider-conveyor-belt/scripts/lib/route_dsl.rb
@@ -256,17 +258,7 @@ module Belt
256
258
  end
257
259
 
258
260
  def singularize(word)
259
- if word.end_with?('ies')
260
- "#{word[0..-4]}y"
261
- elsif word.end_with?('xes') || word.end_with?('zes') || word.end_with?('ses')
262
- word[0..-3]
263
- elsif word.end_with?('ches') || word.end_with?('shes')
264
- word[0..-3]
265
- elsif word.end_with?('s') && !word.end_with?('ss')
266
- word[0..-2]
267
- else
268
- word
269
- end
261
+ Belt::Inflector.singularize(word)
270
262
  end
271
263
  end
272
264
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'inflector'
4
+
3
5
  module Belt
4
6
  # Infers DynamoDB table names from route paths by matching against
5
7
  # tables defined in a Terraform file containing aws_dynamodb_table resources.
@@ -45,27 +47,11 @@ module Belt
45
47
  end
46
48
 
47
49
  def pluralize(word)
48
- if word.end_with?('y')
49
- "#{word[0..-2]}ies"
50
- elsif word.end_with?('s', 'x', 'z', 'ch', 'sh')
51
- "#{word}es"
52
- else
53
- "#{word}s"
54
- end
50
+ Belt::Inflector.pluralize(word)
55
51
  end
56
52
 
57
53
  def singularize(word)
58
- if word.end_with?('ies')
59
- "#{word[0..-4]}y"
60
- elsif word.end_with?('xes', 'zes', 'ses')
61
- word[0..-3]
62
- elsif word.end_with?('ches', 'shes')
63
- word[0..-3]
64
- elsif word.end_with?('s') && !word.end_with?('ss')
65
- word[0..-2]
66
- else
67
- word
68
- end
54
+ Belt::Inflector.singularize(word)
69
55
  end
70
56
  end
71
57
  end
data/lib/belt/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Belt
4
- VERSION = '0.1.7'
4
+ VERSION = '0.1.9'
5
5
  end
@@ -1,7 +1,7 @@
1
1
  terraform {
2
2
  backend "s3" {
3
- bucket = "<%= s3_safe_name(@app_name) %>-terraform-state"
4
- key = "<%= @env_name %>/terraform.tfstate"
3
+ bucket = "belt-terraform-state"
4
+ key = "<%= s3_safe_name(@app_name) %>/<%= @env_name %>/terraform.tfstate"
5
5
  region = "us-east-1"
6
6
  encrypt = true
7
7
  }