belt 0.1.6 → 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.
@@ -5,29 +5,31 @@ 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
12
14
  class GenerateCommand
13
15
  TEMPLATE_DIR = File.expand_path('../../templates/generate', __dir__)
14
- GENERATORS = %w[resource model controller environment frontend views].freeze
16
+ GENERATORS = %w[scaffold resource model controller environment frontend views].freeze
15
17
 
16
18
  include AppDetection
17
19
 
18
20
  FIELD_TYPES = %w[string text integer float boolean date datetime].freeze
19
21
 
20
22
  GENERATOR_HELP = {
21
- 'resource' => {
22
- description: 'Generate a model, controller, routes, and schema for a REST resource.',
23
- usage: 'belt generate resource <name> [field:type ...] [options]',
23
+ 'scaffold' => {
24
+ description: 'Generate a model, controller, routes, schema, and views for a REST resource.',
25
+ usage: 'belt generate scaffold <name> [field:type ...] [options]',
24
26
  options: [
25
27
  ['--skip-views', 'Skip generating frontend view pages']
26
28
  ],
27
29
  examples: [
28
- ['belt g resource post title:string body:text', 'Post with title and body fields'],
29
- ['belt g resource comment author:string body:text status:string', 'Comment with multiple fields'],
30
- ['belt g resource task --skip-views', 'Task resource 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:
@@ -36,6 +38,9 @@ module Belt
36
38
  infrastructure/routes.tf.rb Route entry added
37
39
  infrastructure/schema.tf.rb DynamoDB table schema added
38
40
  lambda/lib/routes/<app>_routes.rb Route manifest updated
41
+ frontend/src/pages/<names>/ React pages (if frontend exists)
42
+
43
+ Alias: `belt g resource` works the same way.
39
44
  NOTES
40
45
  },
41
46
  'model' => {
@@ -43,8 +48,8 @@ module Belt
43
48
  usage: 'belt generate model <name> [field:type ...]',
44
49
  options: [],
45
50
  examples: [
46
- ['belt g model user email:string name:string', 'User model with email and name'],
47
- ['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']
48
53
  ],
49
54
  notes: <<~NOTES
50
55
  Creates:
@@ -56,8 +61,8 @@ module Belt
56
61
  usage: 'belt generate controller <name>',
57
62
  options: [],
58
63
  examples: [
59
- ['belt g controller posts', 'Posts controller with CRUD actions'],
60
- ['belt g controller admin/users', 'Namespaced controller']
64
+ ['belt g controller posts'],
65
+ ['belt g controller admin/users']
61
66
  ],
62
67
  notes: <<~NOTES
63
68
  Creates:
@@ -77,12 +82,15 @@ module Belt
77
82
 
78
83
  unless GENERATORS.include?(generator)
79
84
  puts "Unknown generator: '#{generator}'"
80
- puts "Available generators: #{GENERATORS.join(', ')}"
85
+ puts "Available generators: #{(GENERATORS - ['resource']).join(', ')}"
81
86
  puts "\nRun 'belt generate --help' for usage information."
82
87
  exit 1
83
88
  end
84
89
 
85
- # Per-generator help: belt g resource --help
90
+ # Normalize: resource is an alias for scaffold
91
+ generator = 'scaffold' if generator == 'resource'
92
+
93
+ # Per-generator help: belt g scaffold --help
86
94
  if args.include?('--help') || args.include?('-h')
87
95
  print_generator_help(generator)
88
96
  exit 0
@@ -126,7 +134,7 @@ module Belt
126
134
  errors << 'must only contain letters, numbers, and underscores' unless name.match?(/\A[a-zA-Z][a-zA-Z0-9_]*\z/)
127
135
  errors << 'must not start or end with an underscore' if name.match?(/\A_|_\z/)
128
136
  errors << 'must not contain consecutive underscores' if name.include?('__')
129
- 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))
130
138
  errors << "is a reserved name" if RESERVED_NAMES.include?(name.downcase)
131
139
 
132
140
  return if errors.empty?
@@ -143,23 +151,27 @@ module Belt
143
151
  belt g <generator> <name> [field:type ...] [options]
144
152
 
145
153
  Generators:
146
- resource Generate model, controller, routes, and schema (full REST resource)
154
+ scaffold Generate model, controller, routes, schema, and views (full REST resource)
147
155
  model Generate an ActiveItem model
148
156
  controller Generate a controller
149
157
  environment Create a new deployment environment
150
158
  frontend Scaffold a frontend app (react, vue, svelte)
151
159
  views Generate React pages for a resource
152
160
 
161
+ Aliases:
162
+ resource Same as scaffold
163
+
153
164
  Field Types:
154
165
  #{FIELD_TYPES.join(', ')}
166
+ (defaults to string if omitted)
155
167
 
156
168
  Examples:
157
- belt g resource post title:string body:text status:string
158
- belt g model user email:string name:string
169
+ belt g scaffold post title body:text status
170
+ belt g model user email name
159
171
  belt g controller comments
160
172
  belt g environment staging
161
173
  belt g frontend react
162
- belt g views post title:string body:text
174
+ belt g views post title body:text
163
175
 
164
176
  Run 'belt generate <generator> --help' for detailed help on a specific generator.
165
177
  HELP
@@ -188,21 +200,18 @@ module Belt
188
200
 
189
201
  puts "\nField Types:"
190
202
  puts " #{FIELD_TYPES.join(', ')}"
203
+ puts " (defaults to string if omitted)"
191
204
 
192
205
  puts "\nExamples:"
193
206
  info[:examples].each do |cmd, desc|
194
- puts " #{cmd}"
195
- puts " #{desc}" if desc
207
+ if desc
208
+ puts " #{cmd} — #{desc}"
209
+ else
210
+ puts " #{cmd}"
211
+ end
196
212
  end
197
213
 
198
214
  puts "\n#{info[:notes]}" if info[:notes]
199
-
200
- puts "Naming Rules:"
201
- puts " - Must start with a letter"
202
- puts " - At least 2 characters"
203
- puts " - Letters, numbers, and single underscores only"
204
- puts " - No leading/trailing/consecutive underscores"
205
- puts " - Cannot use reserved names (help, new, create, destroy, etc.)"
206
215
  end
207
216
 
208
217
  def initialize(generator, name, fields, skip_views: false)
@@ -212,15 +221,15 @@ module Belt
212
221
  @skip_views = skip_views
213
222
  @app_name = detect_namespace
214
223
  @module_name = @app_name.split(/[-_]/).map(&:capitalize).join
215
- @resource_name = @name.end_with?('s') ? @name : "#{@name}s"
216
- @singular_name = @name.end_with?('s') ? @name.chomp('s') : @name
217
- @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)
218
227
  end
219
228
 
220
229
  def generate
221
230
  case @generator
222
- when 'resource' then generate_resource
223
- when 'model' then generate_model
231
+ when 'scaffold' then generate_resource
232
+ when 'model' then generate_model_standalone
224
233
  when 'controller' then generate_controller
225
234
  end
226
235
  end
@@ -232,8 +241,9 @@ module Belt
232
241
  generate_controller
233
242
  inject_routes
234
243
  inject_schema
244
+ sync_tables
235
245
  generate_views_if_frontend
236
- puts "\n✓ Resource '#{@singular_name}' generated!"
246
+ puts "\n✓ Scaffold '#{@singular_name}' generated!"
237
247
  puts "\nFiles created/updated:"
238
248
  puts " lambda/models/#{@singular_name}.rb"
239
249
  puts " lambda/controllers/#{@app_name}/#{@resource_name}_controller.rb"
@@ -243,6 +253,12 @@ module Belt
243
253
  puts " frontend/src/pages/#{@resource_name}/ (views)" if Dir.exist?('frontend/src')
244
254
  end
245
255
 
256
+ def generate_model_standalone
257
+ generate_model
258
+ inject_schema
259
+ sync_tables
260
+ end
261
+
246
262
  def generate_model
247
263
  dest = "lambda/models/#{@singular_name}.rb"
248
264
  write_template('model.rb.erb', dest)
@@ -261,14 +277,35 @@ module Belt
261
277
 
262
278
  content = File.read(routes_file)
263
279
  tables_arg = @fields.any? ? ", tables: [:#{@resource_name}]" : ''
280
+ resource_line = "resources :#{@resource_name}#{tables_arg}"
264
281
 
265
- # Insert before the closing `end` of the namespace block
282
+ # Replace the commented placeholder if it exists
266
283
  if content.include?('# resources :posts')
267
- content.sub!('# resources :posts', "resources :#{@resource_name}#{tables_arg}")
268
- elsif content.match?(/namespace :\w+[^\n]*do\n(\s+#[^\n]*\n)*\s+end/)
269
- content.sub!(/^(\s+)(end\s*\z)/m, "\\1 resources :#{@resource_name}#{tables_arg}\n\\1\\2")
284
+ content.sub!('# resources :posts', resource_line)
270
285
  else
271
- content.sub!(/^(\s*end\s*\z)/m, " resources :#{@resource_name}#{tables_arg}\n\\1")
286
+ # Find the target namespace block and insert before its closing `end`
287
+ # Match: `namespace :<name> do` ... `end` (the first `end` at the same indent level)
288
+ namespace_pattern = /^(\s*)namespace :#{Regexp.escape(@app_name)}\b[^\n]*do\s*\n(.*?)^\1end/m
289
+
290
+ if content.match?(namespace_pattern)
291
+ content.sub!(namespace_pattern) do |match|
292
+ indent = $1
293
+ # Insert the resource line before the namespace's closing `end`
294
+ match.sub(/^(#{indent})end\z/m, "#{indent} #{resource_line}\n#{indent}end")
295
+ end
296
+ else
297
+ # Fallback: find any single namespace block and insert into it
298
+ single_ns_pattern = /^(\s*)namespace :\w+\b[^\n]*do\s*\n(.*?)^\1end/m
299
+ if content.match?(single_ns_pattern)
300
+ content.sub!(single_ns_pattern) do |match|
301
+ indent = $1
302
+ match.sub(/^(#{indent})end\z/m, "#{indent} #{resource_line}\n#{indent}end")
303
+ end
304
+ else
305
+ # No namespace block found at all — insert at top level before final end
306
+ content.sub!(/^(\s*end\s*)\z/m, " #{resource_line}\n\\1")
307
+ end
308
+ end
272
309
  end
273
310
 
274
311
  File.write(routes_file, content)
@@ -343,6 +380,10 @@ module Belt
343
380
  puts " update #{schema_file}"
344
381
  end
345
382
 
383
+ def sync_tables
384
+ Belt::CLI::TablesCommand.sync_all_environments
385
+ end
386
+
346
387
  def write_template(template_name, dest_path)
347
388
  template_path = File.join(TEMPLATE_DIR, template_name)
348
389
  FileUtils.mkdir_p(File.dirname(dest_path))
@@ -82,8 +82,8 @@ module Belt
82
82
 
83
83
  puts "Creating new Belt application: #{@app_name}"
84
84
  create_structure
85
- generate_frontend if @frontend
86
85
  generate_environments
86
+ generate_frontend if @frontend
87
87
  init_git
88
88
  run_bundle_install
89
89
  setup_state
@@ -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",
@@ -16,6 +16,10 @@ module Belt
16
16
 
17
17
  return non_param.map { |s| s.gsub('-', '_') }.join('/') if route.resource? && nested_resource?(segments)
18
18
 
19
+ # For non-resource routes with a single segment (e.g., post '/signup' in :onboarding),
20
+ # the segment is the action name, not the controller. Use the gateway name as controller.
21
+ return gateway.name if !route.resource? && non_param.length == 1
22
+
19
23
  non_param.first.gsub('-', '_')
20
24
  end
21
25
 
@@ -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 />} />"
data/lib/belt/cli.rb CHANGED
@@ -5,6 +5,7 @@ require_relative 'root'
5
5
  require_relative 'cli/env_resolver'
6
6
  require_relative 'cli/new_command'
7
7
  require_relative 'cli/generate_command'
8
+ require_relative 'cli/destroy_command'
8
9
  require_relative 'cli/frontend_command'
9
10
  require_relative 'cli/frontend_setup_command'
10
11
  require_relative 'cli/frontend_deploy_command'
@@ -22,6 +23,7 @@ module Belt
22
23
  COMMANDS_DEFINITION = {
23
24
  'new' => Belt::CLI::NewCommand,
24
25
  %w[generate g] => Belt::CLI::GenerateCommand,
26
+ %w[destroy d] => Belt::CLI::DestroyCommand,
25
27
  'routes' => Belt::CLI::RoutesCommand,
26
28
  %w[console c] => Belt::CLI::ConsoleCommand,
27
29
  %w[tasks --tasks -T] => Belt::CLI::TasksCommand,
@@ -45,7 +47,21 @@ module Belt
45
47
  exit 1
46
48
  end
47
49
 
48
- # Terraform shorthand: belt init wups, belt plan wups, belt apply wups
50
+ # `belt destroy` is ambiguous: could be terraform destroy or belt destroy <generator>.
51
+ # Route to DestroyCommand if the first arg is a known generator or help flag.
52
+ if command == 'destroy'
53
+ if args.empty? || args.first =~ /\A-/
54
+ # belt destroy --help → DestroyCommand help
55
+ # belt destroy (no args) → terraform destroy (needs env)
56
+ if args.include?('--help') || args.include?('-h')
57
+ return DestroyCommand.run(args)
58
+ end
59
+ elsif DestroyCommand::GENERATORS.include?(args.first)
60
+ return DestroyCommand.run(args)
61
+ end
62
+ end
63
+
64
+ # Terraform shorthand: belt init wups, belt plan wups, belt apply wups, belt destroy wups
49
65
  return Belt::CLI::TerraformCommand.run(command, args) if TERRAFORM_ACTIONS.include?(command)
50
66
 
51
67
  handler = COMMANDS[command]
@@ -71,10 +87,14 @@ module Belt
71
87
 
72
88
  Commands:
73
89
  new <app_name> [--frontend react] Create a new Belt application
74
- generate <resource|model|controller> <name> Generate components
90
+ generate <scaffold|model|controller> <name> Generate components
75
91
  generate frontend <react|vue|svelte> Scaffold a frontend app
76
92
  generate views <resource> [fields...] Generate React pages for REST actions
77
93
  generate environment <name> Create a new environment
94
+ destroy <scaffold|model|controller> <name> Remove generated components
95
+ destroy frontend Remove the frontend/ directory
96
+ destroy views <resource> Remove React pages for a resource
97
+ destroy environment <name> Remove an environment directory
78
98
  server Start local dev server (frontend)
79
99
  s Alias for server
80
100
  deploy [environment] Deploy to AWS (init → plan → apply)
@@ -106,7 +126,8 @@ module Belt
106
126
 
107
127
  Examples:
108
128
  belt new blog --frontend react
109
- belt generate resource post title:string content:text status:string
129
+ belt generate scaffold post title:string content:text status:string
130
+ belt destroy scaffold post
110
131
  belt generate frontend react
111
132
  belt server # Start local frontend server
112
133
  belt deploy # Deploy dev to AWS
@@ -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.