belt 0.1.7 → 0.1.8

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: 479b619266e398d1d3052c96eb18f05c8452306a01aa46576b82e072372820b5
4
- data.tar.gz: d5697750193b1a1cbba8d39e81190035d8fd40db490efbdb24903e7647374767
3
+ metadata.gz: ebbefe3f44abda51ac10b219cda18678ca834d06d8eab39e31beb3e48e99b25a
4
+ data.tar.gz: 3233b91c7297982ef7f5037331ef003c3637ebc6dccf4321b4a5f758ed57f1d9
5
5
  SHA512:
6
- metadata.gz: 73ac84ffd0e2238b929180f89972f70c55a7d43a92d0f9de23b85cc6c318982c40c9466f49fe1d2d0960a47685acc90e1352b43f7e390ffee2951c83568ba432
7
- data.tar.gz: 74db2374a336ba5ac774a5a8a4f7c053136de9dc652907e4abeebd69ec6bfa6485f35d3339787e7e5b17feadb3cf23014ae356d8050e817c90a2ba1fbf817ba6
6
+ metadata.gz: af38bf5330f69577f37ab34a0bd7510b5d84aa14969efd8975ac6875ea85450aaa741beeb71bf3f51f627af11c0c5445806081156706cdfd4901fba4301e33ed
7
+ data.tar.gz: 2eade1680ebfe76e78ab38e38d273565cf34eb1f1789427d358706ac2c4c128a5f86448a5eb2a146161be8c912bf53f9229fceb0904719c9df5ef3444dc3481e
@@ -2,6 +2,8 @@
2
2
 
3
3
  require 'fileutils'
4
4
  require_relative 'app_detection'
5
+ require_relative 'tables_command'
6
+ require_relative '../inflector'
5
7
 
6
8
  module Belt
7
9
  module CLI
@@ -93,13 +95,9 @@ module Belt
93
95
  @name = name&.downcase&.gsub(/[^a-z0-9_]/, '_')
94
96
  @fields = fields
95
97
  @app_name = detect_namespace
96
- @resource_name = if @name
97
- @name.end_with?('s') ? @name : "#{@name}s"
98
- end
99
- @singular_name = if @name
100
- @name.end_with?('s') ? @name.chomp('s') : @name
101
- end
102
- @class_name = @singular_name&.split('_')&.map(&:capitalize)&.join
98
+ @singular_name = @name ? Belt::Inflector.singularize(@name) : nil
99
+ @resource_name = @singular_name ? Belt::Inflector.pluralize(@singular_name) : nil
100
+ @class_name = @singular_name ? Belt::Inflector.classify(@singular_name) : nil
103
101
  @removed = []
104
102
  @updated = []
105
103
  end
@@ -124,6 +122,7 @@ module Belt
124
122
  destroy_controller
125
123
  remove_routes
126
124
  remove_schema
125
+ sync_tables
127
126
  destroy_views
128
127
  puts "\n✓ Scaffold '#{@singular_name}' destroyed!"
129
128
  end
@@ -157,6 +156,17 @@ module Belt
157
156
  FileUtils.rm_rf(dir)
158
157
  @removed << dir
159
158
  puts " remove #{dir}/"
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)
168
+ end
169
+
160
170
  puts "\n✓ Frontend removed!"
161
171
  else
162
172
  puts '✗ No frontend/ directory found.'
@@ -164,10 +174,29 @@ module Belt
164
174
  end
165
175
  end
166
176
 
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
+
167
195
  def destroy_views
168
196
  return unless @resource_name
169
197
 
170
198
  pages_dir = "frontend/src/pages/#{@resource_name}"
199
+
171
200
  if Dir.exist?(pages_dir)
172
201
  FileUtils.rm_rf(pages_dir)
173
202
  @removed << pages_dir
@@ -255,6 +284,10 @@ module Belt
255
284
  end
256
285
  end
257
286
 
287
+ def sync_tables
288
+ Belt::CLI::TablesCommand.sync_all_environments
289
+ end
290
+
258
291
  def remove_view_routes
259
292
  app_jsx = 'frontend/src/App.jsx'
260
293
  return unless File.exist?(app_jsx)
@@ -39,6 +39,7 @@ module Belt
39
39
  def run
40
40
  validate!
41
41
  generate_frontend_tf
42
+ update_main_tf_frontend_urls
42
43
  return if @quiet
43
44
 
44
45
  puts "\n✓ Frontend infrastructure generated for '#{@env}'!"
@@ -62,6 +63,35 @@ module Belt
62
63
  File.write(dest, content)
63
64
  puts " create #{dest}" unless @quiet
64
65
  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
65
95
  end
66
96
  end
67
97
  end
@@ -5,7 +5,9 @@ require 'erb'
5
5
  require_relative 'app_detection'
6
6
  require_relative 'environment_command'
7
7
  require_relative 'frontend_command'
8
+ require_relative 'tables_command'
8
9
  require_relative 'views_command'
10
+ require_relative '../inflector'
9
11
 
10
12
  module Belt
11
13
  module CLI
@@ -25,9 +27,9 @@ module Belt
25
27
  ['--skip-views', 'Skip generating frontend view pages']
26
28
  ],
27
29
  examples: [
28
- ['belt g scaffold post title:string body:text', 'Post with title and body fields'],
29
- ['belt g scaffold comment author:string body:text status:string', 'Scaffold with multiple fields'],
30
- ['belt g scaffold task --skip-views', 'Scaffold without frontend pages']
30
+ ['belt g scaffold post title body:text'],
31
+ ['belt g scaffold comment author body:text status'],
32
+ ['belt g scaffold task --skip-views']
31
33
  ],
32
34
  notes: <<~NOTES
33
35
  Creates:
@@ -46,8 +48,8 @@ module Belt
46
48
  usage: 'belt generate model <name> [field:type ...]',
47
49
  options: [],
48
50
  examples: [
49
- ['belt g model user email:string name:string', 'User model with email and name'],
50
- ['belt g model event title:string starts_at:datetime', 'Event model with datetime field']
51
+ ['belt g model user email name'],
52
+ ['belt g model event title starts_at:datetime']
51
53
  ],
52
54
  notes: <<~NOTES
53
55
  Creates:
@@ -59,8 +61,8 @@ module Belt
59
61
  usage: 'belt generate controller <name>',
60
62
  options: [],
61
63
  examples: [
62
- ['belt g controller posts', 'Posts controller with CRUD actions'],
63
- ['belt g controller admin/users', 'Namespaced controller']
64
+ ['belt g controller posts'],
65
+ ['belt g controller admin/users']
64
66
  ],
65
67
  notes: <<~NOTES
66
68
  Creates:
@@ -132,7 +134,7 @@ module Belt
132
134
  errors << 'must only contain letters, numbers, and underscores' unless name.match?(/\A[a-zA-Z][a-zA-Z0-9_]*\z/)
133
135
  errors << 'must not start or end with an underscore' if name.match?(/\A_|_\z/)
134
136
  errors << 'must not contain consecutive underscores' if name.include?('__')
135
- errors << "is a reserved name" if RESERVED_NAMES.include?(name.downcase.chomp('s'))
137
+ errors << "is a reserved name" if RESERVED_NAMES.include?(Belt::Inflector.singularize(name.downcase))
136
138
  errors << "is a reserved name" if RESERVED_NAMES.include?(name.downcase)
137
139
 
138
140
  return if errors.empty?
@@ -161,14 +163,15 @@ module Belt
161
163
 
162
164
  Field Types:
163
165
  #{FIELD_TYPES.join(', ')}
166
+ (defaults to string if omitted)
164
167
 
165
168
  Examples:
166
- belt g scaffold post title:string body:text status:string
167
- belt g model user email:string name:string
169
+ belt g scaffold post title body:text status
170
+ belt g model user email name
168
171
  belt g controller comments
169
172
  belt g environment staging
170
173
  belt g frontend react
171
- belt g views post title:string body:text
174
+ belt g views post title body:text
172
175
 
173
176
  Run 'belt generate <generator> --help' for detailed help on a specific generator.
174
177
  HELP
@@ -197,21 +200,18 @@ module Belt
197
200
 
198
201
  puts "\nField Types:"
199
202
  puts " #{FIELD_TYPES.join(', ')}"
203
+ puts " (defaults to string if omitted)"
200
204
 
201
205
  puts "\nExamples:"
202
206
  info[:examples].each do |cmd, desc|
203
- puts " #{cmd}"
204
- puts " #{desc}" if desc
207
+ if desc
208
+ puts " #{cmd} — #{desc}"
209
+ else
210
+ puts " #{cmd}"
211
+ end
205
212
  end
206
213
 
207
214
  puts "\n#{info[:notes]}" if info[:notes]
208
-
209
- puts "Naming Rules:"
210
- puts " - Must start with a letter"
211
- puts " - At least 2 characters"
212
- puts " - Letters, numbers, and single underscores only"
213
- puts " - No leading/trailing/consecutive underscores"
214
- puts " - Cannot use reserved names (help, new, create, destroy, etc.)"
215
215
  end
216
216
 
217
217
  def initialize(generator, name, fields, skip_views: false)
@@ -221,15 +221,15 @@ module Belt
221
221
  @skip_views = skip_views
222
222
  @app_name = detect_namespace
223
223
  @module_name = @app_name.split(/[-_]/).map(&:capitalize).join
224
- @resource_name = @name.end_with?('s') ? @name : "#{@name}s"
225
- @singular_name = @name.end_with?('s') ? @name.chomp('s') : @name
226
- @class_name = @singular_name.split('_').map(&:capitalize).join
224
+ @singular_name = Belt::Inflector.singularize(@name)
225
+ @resource_name = Belt::Inflector.pluralize(@singular_name)
226
+ @class_name = Belt::Inflector.classify(@singular_name)
227
227
  end
228
228
 
229
229
  def generate
230
230
  case @generator
231
231
  when 'scaffold' then generate_resource
232
- when 'model' then generate_model
232
+ when 'model' then generate_model_standalone
233
233
  when 'controller' then generate_controller
234
234
  end
235
235
  end
@@ -241,6 +241,7 @@ module Belt
241
241
  generate_controller
242
242
  inject_routes
243
243
  inject_schema
244
+ sync_tables
244
245
  generate_views_if_frontend
245
246
  puts "\n✓ Scaffold '#{@singular_name}' generated!"
246
247
  puts "\nFiles created/updated:"
@@ -252,6 +253,12 @@ module Belt
252
253
  puts " frontend/src/pages/#{@resource_name}/ (views)" if Dir.exist?('frontend/src')
253
254
  end
254
255
 
256
+ def generate_model_standalone
257
+ generate_model
258
+ inject_schema
259
+ sync_tables
260
+ end
261
+
255
262
  def generate_model
256
263
  dest = "lambda/models/#{@singular_name}.rb"
257
264
  write_template('model.rb.erb', dest)
@@ -373,6 +380,10 @@ module Belt
373
380
  puts " update #{schema_file}"
374
381
  end
375
382
 
383
+ def sync_tables
384
+ Belt::CLI::TablesCommand.sync_all_environments
385
+ end
386
+
376
387
  def write_template(template_name, dest_path)
377
388
  template_path = File.join(TEMPLATE_DIR, template_name)
378
389
  FileUtils.mkdir_p(File.dirname(dest_path))
@@ -109,7 +109,6 @@ module Belt
109
109
  %W[
110
110
  #{@app_name}/lambda/controllers/api
111
111
  #{@app_name}/lambda/models
112
- #{@app_name}/lambda/models/concerns
113
112
  #{@app_name}/lambda/lib/routes
114
113
  #{@app_name}/lambda/config
115
114
  #{@app_name}/lambda/spec
@@ -124,7 +123,6 @@ module Belt
124
123
  'lambda/api.rb.erb' => "#{@app_name}/lambda/api.rb",
125
124
  'lambda/config/environment.rb.erb' => "#{@app_name}/lambda/config/environment.rb",
126
125
  '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
126
  'lambda/controllers/application_controller.rb.erb' =>
129
127
  "#{@app_name}/lambda/controllers/api/application_controller.rb",
130
128
  'lambda/lib/routes/routes.rb.erb' => "#{@app_name}/lambda/lib/routes/api_routes.rb",
@@ -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
@@ -2,6 +2,8 @@
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
@@ -27,8 +29,22 @@ module Belt
27
29
  new(env).run
28
30
  end
29
31
 
30
- def initialize(env)
32
+ # Automatically sync dynamodb.tf for all existing environments.
33
+ # Called by generators after updating schema.tf.rb.
34
+ def self.sync_all_environments
35
+ return unless File.exist?(SCHEMA_FILE)
36
+
37
+ environments = TerraformCommand.list_environments
38
+ return if environments.empty?
39
+
40
+ environments.each do |env|
41
+ new(env, quiet: true).run
42
+ end
43
+ end
44
+
45
+ def initialize(env, quiet: false)
31
46
  @env = env
47
+ @quiet = quiet
32
48
  @app_name = detect_app_name
33
49
  @env_dir = "infrastructure/#{@env}"
34
50
  end
@@ -37,8 +53,8 @@ module Belt
37
53
  validate!
38
54
  models = parse_schema
39
55
  if models.empty?
40
- puts "No models found in #{SCHEMA_FILE}"
41
- exit 0
56
+ puts "No models found in #{SCHEMA_FILE}" unless @quiet
57
+ return
42
58
  end
43
59
 
44
60
  generate_dynamodb_tf(models)
@@ -47,32 +63,54 @@ module Belt
47
63
  private
48
64
 
49
65
  def validate!
50
- abort "Error: #{SCHEMA_FILE} not found. Run `belt generate resource` first." unless File.exist?(SCHEMA_FILE)
66
+ unless File.exist?(SCHEMA_FILE)
67
+ abort "Error: #{SCHEMA_FILE} not found. Run `belt generate resource` first." unless @quiet
68
+ return
69
+ end
51
70
  return if Dir.exist?(@env_dir)
52
71
 
53
- abort "Error: Environment '#{@env}' not found at #{@env_dir}/.\n" \
54
- "Create it with: belt generate environment #{@env}"
72
+ unless @quiet
73
+ abort "Error: Environment '#{@env}' not found at #{@env_dir}/.\n" \
74
+ "Create it with: belt generate environment #{@env}"
75
+ end
55
76
  end
56
77
 
57
78
  def parse_schema
79
+ return [] unless File.exist?(SCHEMA_FILE)
80
+
58
81
  parser = SchemaParser.new
59
82
  schema_content = File.read(SCHEMA_FILE)
60
83
 
61
84
  # Replace DSL wrapper with direct parser call
62
85
  # 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/, '')
86
+ inner = schema_content.sub(/\A(?:Belt\.application)\.schema\.draw do\n?/, '').sub(/\n?end\s*\z/, '')
87
+ inner.strip!
88
+
89
+ return [] if inner.empty? || inner.match?(/\A\s*\z/m)
90
+
64
91
  parser.instance_eval(inner, SCHEMA_FILE)
65
92
  parser.models
66
93
  end
67
94
 
68
95
  def generate_dynamodb_tf(models)
69
96
  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."
97
+ existing_content = File.exist?(dest) ? File.read(dest) : nil
98
+ new_content = render_dynamodb(models)
99
+
100
+ # Skip if content is unchanged
101
+ return if existing_content == new_content
102
+
103
+ File.write(dest, new_content)
104
+
105
+ if @quiet
106
+ verb = existing_content ? 'update' : 'create'
107
+ puts " #{verb} #{dest}"
108
+ else
109
+ puts " create #{dest}"
110
+ puts "\n✓ Generated DynamoDB tables for #{models.size} model(s):"
111
+ models.each { |m| puts " • #{table_name(m[:name])}" }
112
+ puts "\nRun `belt deploy` to create them."
113
+ end
76
114
  end
77
115
 
78
116
  def render_dynamodb(models)
@@ -84,7 +122,7 @@ module Belt
84
122
  def render_table(model)
85
123
  name = table_name(model[:name])
86
124
  <<~HCL
87
- resource "aws_dynamodb_table" "#{model[:name]}s" {
125
+ resource "aws_dynamodb_table" "#{Belt::Inflector.pluralize(model[:name])}" {
88
126
  name = "#{name}"
89
127
  billing_mode = "PAY_PER_REQUEST"
90
128
  hash_key = "id"
@@ -104,7 +142,7 @@ module Belt
104
142
  end
105
143
 
106
144
  def table_name(model_name)
107
- "#{@app_name}-#{@env}-#{model_name}s"
145
+ "#{@app_name}-#{@env}-#{Belt::Inflector.pluralize(model_name)}"
108
146
  end
109
147
 
110
148
  # 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.8'
5
5
  end
@@ -38,7 +38,7 @@ resource "conveyor_belt" "main" {
38
38
  app_name = var.app_name
39
39
  lambda_source_dir = "${path.module}/../../lambda"
40
40
  lambda_shared_dirs = ["controllers", "helpers", "lib", "models", "views"]
41
- frontend_urls = ["http://localhost:3000"]
41
+ frontend_urls = var.environment == "prod" ? [] : ["http://localhost:3000"]
42
42
  friendly_errors = var.environment != "prod"
43
43
 
44
44
  # Per-lambda configuration: env vars, timeouts, memory
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'application_controller'
4
- require_relative '../../models/<%= @singular_name %>'
5
4
 
6
5
  module <%= @module_name %>Controllers
7
6
  class <%= @class_name %>sController < ApplicationController
@@ -1,8 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class <%= @class_name %> < ApplicationRecord
4
- include Concerns::Timestampable
5
-
6
4
  <% @fields.each do |field| -%>
7
5
  attr_accessor :<%= field[:name] %>
8
6
  <% end -%>
@@ -15,4 +15,10 @@ end
15
15
 
16
16
  # Load lib and models
17
17
  Dir[File.join(__dir__, '..', 'lib', '**', '*.rb')].sort.each { |f| require f }
18
- Dir[File.join(__dir__, '..', 'models', '**', '*.rb')].sort.each { |f| require f }
18
+
19
+ models_dir = File.join(__dir__, '..', 'models')
20
+ all_models = Dir[File.join(models_dir, '**', '*.rb')].sort
21
+ app_record = all_models.find { |f| File.basename(f) == 'application_record.rb' }
22
+ rest = all_models - [app_record].compact
23
+ require app_record if app_record
24
+ rest.each { |f| require f }
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: belt
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.7
4
+ version: 0.1.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stowzilla
@@ -23,6 +23,20 @@ dependencies:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
25
  version: '0.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: activesupport
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '7.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '7.0'
26
40
  - !ruby/object:Gem::Dependency
27
41
  name: lambda_loadout
28
42
  requirement: !ruby/object:Gem::Requirement
@@ -80,6 +94,7 @@ files:
80
94
  - lib/belt/helpers/cors_origin.rb
81
95
  - lib/belt/helpers/error_logging.rb
82
96
  - lib/belt/helpers/response.rb
97
+ - lib/belt/inflector.rb
83
98
  - lib/belt/lambda_handler.rb
84
99
  - lib/belt/observability.rb
85
100
  - lib/belt/parameters.rb
@@ -118,7 +133,6 @@ files:
118
133
  - lib/templates/new_app/lambda/controllers/application_controller.rb.erb
119
134
  - lib/templates/new_app/lambda/lib/routes/routes.rb.erb
120
135
  - lib/templates/new_app/lambda/models/application_record.rb.erb
121
- - lib/templates/new_app/lambda/models/concerns/timestampable.rb.erb
122
136
  - lib/templates/views/Edit.jsx.erb
123
137
  - lib/templates/views/Form.jsx.erb
124
138
  - lib/templates/views/Index.jsx.erb
@@ -1,23 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Concerns
4
- module Timestampable
5
- def self.included(base)
6
- base.attr_accessor :created_at, :updated_at
7
- base.set_callback :create, :before, :set_timestamps
8
- base.set_callback :update, :before, :set_updated_at
9
- end
10
-
11
- private
12
-
13
- def set_timestamps
14
- now = Time.now.utc.iso8601
15
- self.created_at ||= now
16
- self.updated_at = now
17
- end
18
-
19
- def set_updated_at
20
- self.updated_at = Time.now.utc.iso8601
21
- end
22
- end
23
- end