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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 479b619266e398d1d3052c96eb18f05c8452306a01aa46576b82e072372820b5
4
- data.tar.gz: d5697750193b1a1cbba8d39e81190035d8fd40db490efbdb24903e7647374767
3
+ metadata.gz: c3c75f106f7a0f2ebf672294ee30e96f96745fb3ecbd142307c08b6b006933c8
4
+ data.tar.gz: 470cefb769cfab7dbaf80e6f950eb62cf87fb5a6b297f3661cf147c26d494501
5
5
  SHA512:
6
- metadata.gz: 73ac84ffd0e2238b929180f89972f70c55a7d43a92d0f9de23b85cc6c318982c40c9466f49fe1d2d0960a47685acc90e1352b43f7e390ffee2951c83568ba432
7
- data.tar.gz: 74db2374a336ba5ac774a5a8a4f7c053136de9dc652907e4abeebd69ec6bfa6485f35d3339787e7e5b17feadb3cf23014ae356d8050e817c90a2ba1fbf817ba6
6
+ metadata.gz: e5c5ed3dfa729483fd54e28197c9bd2f47f3581bea5f0892a4ac44c02f55253809f4fe1c12167ca55a040bdebc97e545b462f08d2f42c23dd1304a4a9dfd7768
7
+ data.tar.gz: be36355b315e467f411079880a4b3e14e94b56567cbf1892a95fff3f2b3c1c9a8bfe91e2e8618b405b1b06045ee1c06e999c12a6c281b9c490b8e8dc43d8f999
@@ -503,8 +503,48 @@ module Belt
503
503
  output.each_line do |line|
504
504
  puts " #{line}"
505
505
  end
506
+
507
+ # Show DNS guidance if name_servers are present and this looks like a first deploy
508
+ print_dns_guidance if output.include?('name_servers')
506
509
  end
507
510
  end
511
+
512
+ def print_dns_guidance
513
+ # Check if there's a marker file indicating DNS guidance was already shown
514
+ marker = File.join(@infra_dir, @env, '.dns_configured')
515
+ return if File.exist?(marker)
516
+
517
+ domain = detect_domain_from_tfvars
518
+ return unless domain
519
+
520
+ puts ''
521
+ puts ' ┌─────────────────────────────────────────────────────────────┐'
522
+ puts ' │ DNS SETUP REQUIRED │'
523
+ puts ' ├─────────────────────────────────────────────────────────────┤'
524
+ puts ' │ Point your domain to the name_servers shown above. │'
525
+ puts ' │ │'
526
+ puts ' │ • External registrar (GoDaddy, Namecheap, etc.): │'
527
+ puts ' │ Update nameservers in your registrar\'s DNS settings. │'
528
+ puts ' │ │'
529
+ puts ' │ • Domain in AWS Route53 Registered Domains: │'
530
+ puts ' │ Route53 → Registered Domains → Name servers → Edit. │'
531
+ puts ' │ │'
532
+ puts " │ Verify: dig +short NS #{domain.ljust(38)}│"
533
+ puts ' │ │'
534
+ puts ' │ This message won\'t appear on subsequent deploys. │'
535
+ puts ' └─────────────────────────────────────────────────────────────┘'
536
+
537
+ # Create the marker so we don't nag on every deploy
538
+ File.write(marker, "DNS guidance shown at #{Time.now}\n")
539
+ end
540
+
541
+ def detect_domain_from_tfvars
542
+ tfvars_file = File.join(@infra_dir, @env, 'terraform.tfvars')
543
+ return nil unless File.exist?(tfvars_file)
544
+
545
+ match = File.read(tfvars_file).match(/^\s*domain\s*=\s*"([^"]+)"/)
546
+ match[1] if match
547
+ end
508
548
  end
509
549
  end
510
550
  end
@@ -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
@@ -123,7 +121,6 @@ module Belt
123
121
  destroy_model
124
122
  destroy_controller
125
123
  remove_routes
126
- remove_schema
127
124
  destroy_views
128
125
  puts "\n✓ Scaffold '#{@singular_name}' destroyed!"
129
126
  end
@@ -131,6 +128,8 @@ module Belt
131
128
  def destroy_model
132
129
  path = "lambda/models/#{@singular_name}.rb"
133
130
  remove_file(path)
131
+ remove_schema
132
+ sync_tables
134
133
  end
135
134
 
136
135
  def destroy_controller
@@ -157,6 +156,15 @@ module Belt
157
156
  FileUtils.rm_rf(dir)
158
157
  @removed << dir
159
158
  puts " remove #{dir}/"
159
+
160
+ # Remove frontend.tf from the module
161
+ module_frontend = 'infrastructure/modules/app/frontend.tf'
162
+ if File.exist?(module_frontend)
163
+ FileUtils.rm_f(module_frontend)
164
+ @removed << module_frontend
165
+ puts " remove #{module_frontend}"
166
+ end
167
+
160
168
  puts "\n✓ Frontend removed!"
161
169
  else
162
170
  puts '✗ No frontend/ directory found.'
@@ -168,6 +176,7 @@ module Belt
168
176
  return unless @resource_name
169
177
 
170
178
  pages_dir = "frontend/src/pages/#{@resource_name}"
179
+
171
180
  if Dir.exist?(pages_dir)
172
181
  FileUtils.rm_rf(pages_dir)
173
182
  @removed << pages_dir
@@ -255,6 +264,10 @@ module Belt
255
264
  end
256
265
  end
257
266
 
267
+ def sync_tables
268
+ Belt::CLI::TablesCommand.sync_all_environments
269
+ end
270
+
258
271
  def remove_view_routes
259
272
  app_jsx = 'frontend/src/App.jsx'
260
273
  return unless File.exist?(app_jsx)
@@ -26,9 +26,10 @@ module Belt
26
26
  new(env_name).generate
27
27
  end
28
28
 
29
- def initialize(env_name, quiet: false)
29
+ def initialize(env_name, quiet: false, domain: nil)
30
30
  @env_name = env_name.downcase.gsub(/[^a-z0-9_-]/, '')
31
31
  @app_name = detect_app_name
32
+ @domain = domain
32
33
  @quiet = quiet
33
34
  end
34
35
 
@@ -51,28 +52,19 @@ module Belt
51
52
 
52
53
  puts "\n✓ Environment '#{@env_name}' created!"
53
54
 
54
- setup_frontend_if_exists
55
-
56
55
  unless @quiet
57
56
  puts "\nNext steps:"
58
57
  puts " cd #{dest_dir}"
59
58
  puts ' terraform init'
60
59
  puts ' terraform plan'
61
60
  puts ' terraform apply'
61
+ puts "\nTo configure a custom domain, set `domain` in #{dest_dir}/terraform.tfvars:"
62
+ puts " domain = \"myapp.com\""
62
63
  end
63
64
  end
64
65
 
65
66
  private
66
67
 
67
- def setup_frontend_if_exists
68
- return unless Dir.exist?('frontend') && !Dir.empty?('frontend')
69
-
70
- puts "\n Frontend detected — generating frontend infrastructure..."
71
- require_relative 'frontend_setup_command'
72
- FrontendSetupCommand.new(@env_name, quiet: true).run
73
- puts " create infrastructure/#{@env_name}/frontend.tf"
74
- end
75
-
76
68
  def templates
77
69
  {
78
70
  'main.tf.erb' => 'main.tf',
@@ -75,22 +75,20 @@ module Belt
75
75
  end
76
76
 
77
77
  def setup_frontend_infra_for_existing_environments
78
- environments = detect_environments
79
- return if environments.empty?
78
+ module_dir = 'infrastructure/modules/app'
79
+ frontend_tf = File.join(module_dir, 'frontend.tf')
80
80
 
81
- puts "\n Setting up frontend infrastructure for existing environments..."
82
- require_relative 'frontend_setup_command'
81
+ if File.exist?(frontend_tf)
82
+ puts " skip #{frontend_tf} (already exists)"
83
+ return
84
+ end
83
85
 
84
- environments.each do |env_name|
85
- frontend_tf = "infrastructure/#{env_name}/frontend.tf"
86
- if File.exist?(frontend_tf)
87
- puts " skip #{frontend_tf} (already exists)"
88
- next
89
- end
86
+ return unless Dir.exist?(module_dir)
90
87
 
91
- FrontendSetupCommand.new(env_name, quiet: true).run
92
- puts " create #{frontend_tf}"
93
- end
88
+ puts "\n Setting up frontend infrastructure..."
89
+ require_relative 'frontend_setup_command'
90
+ FrontendSetupCommand.new(nil, quiet: true).run
91
+ puts " create #{frontend_tf}"
94
92
  end
95
93
 
96
94
  def copy_template(src_dir, dest_dir)
@@ -8,31 +8,20 @@ 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
 
@@ -41,22 +30,22 @@ module Belt
41
30
  generate_frontend_tf
42
31
  return if @quiet
43
32
 
44
- puts "\n✓ Frontend infrastructure generated for '#{@env}'!"
45
- puts "\nRun `belt apply #{@env}` to create the S3 bucket and CloudFront distribution."
46
- 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."
47
36
  end
48
37
 
49
38
  private
50
39
 
51
40
  def validate!
52
- return if Dir.exist?(@env_dir)
41
+ return if Dir.exist?(MODULE_DIR)
53
42
 
54
- abort "Error: Environment '#{@env}' not found at #{@env_dir}/.\n" \
55
- "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."
56
45
  end
57
46
 
58
47
  def generate_frontend_tf
59
- dest = File.join(@env_dir, 'frontend.tf')
48
+ dest = File.join(MODULE_DIR, 'frontend.tf')
60
49
  template_path = File.join(TEMPLATE_DIR, 'frontend.tf.erb')
61
50
  content = ERB.new(File.read(template_path), trim_mode: '-').result(binding)
62
51
  File.write(dest, content)
@@ -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
@@ -22,12 +24,14 @@ module Belt
22
24
  description: 'Generate a model, controller, routes, schema, and views for a REST resource.',
23
25
  usage: 'belt generate scaffold <name> [field:type ...] [options]',
24
26
  options: [
25
- ['--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)']
26
29
  ],
27
30
  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']
31
+ ['belt g scaffold post title body:text'],
32
+ ['belt g scaffold comment author body:text status'],
33
+ ['belt g scaffold task --skip-views'],
34
+ ['belt g scaffold post title body:text --force']
31
35
  ],
32
36
  notes: <<~NOTES
33
37
  Creates:
@@ -43,11 +47,13 @@ module Belt
43
47
  },
44
48
  'model' => {
45
49
  description: 'Generate an ActiveItem model with validations and DynamoDB field definitions.',
46
- usage: 'belt generate model <name> [field:type ...]',
47
- options: [],
50
+ usage: 'belt generate model <name> [field:type ...] [options]',
51
+ options: [
52
+ ['--force, -f', 'Overwrite existing model files (skip collision check)']
53
+ ],
48
54
  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']
55
+ ['belt g model user email name'],
56
+ ['belt g model event title starts_at:datetime']
51
57
  ],
52
58
  notes: <<~NOTES
53
59
  Creates:
@@ -59,8 +65,8 @@ module Belt
59
65
  usage: 'belt generate controller <name>',
60
66
  options: [],
61
67
  examples: [
62
- ['belt g controller posts', 'Posts controller with CRUD actions'],
63
- ['belt g controller admin/users', 'Namespaced controller']
68
+ ['belt g controller posts'],
69
+ ['belt g controller admin/users']
64
70
  ],
65
71
  notes: <<~NOTES
66
72
  Creates:
@@ -108,9 +114,10 @@ module Belt
108
114
 
109
115
  validate_resource_name!(name, generator)
110
116
 
117
+ force = args.delete('--force') || args.delete('-f')
111
118
  skip_views = args.delete('--skip-views')
112
119
  fields = args.map { |arg| parse_field(arg) }
113
- new(generator, name, fields, skip_views: skip_views).generate
120
+ new(generator, name, fields, skip_views: skip_views, force: force).generate
114
121
  end
115
122
 
116
123
  def self.parse_field(arg)
@@ -132,7 +139,7 @@ module Belt
132
139
  errors << 'must only contain letters, numbers, and underscores' unless name.match?(/\A[a-zA-Z][a-zA-Z0-9_]*\z/)
133
140
  errors << 'must not start or end with an underscore' if name.match?(/\A_|_\z/)
134
141
  errors << 'must not contain consecutive underscores' if name.include?('__')
135
- errors << "is a reserved name" if RESERVED_NAMES.include?(name.downcase.chomp('s'))
142
+ errors << "is a reserved name" if RESERVED_NAMES.include?(Belt::Inflector.singularize(name.downcase))
136
143
  errors << "is a reserved name" if RESERVED_NAMES.include?(name.downcase)
137
144
 
138
145
  return if errors.empty?
@@ -161,14 +168,15 @@ module Belt
161
168
 
162
169
  Field Types:
163
170
  #{FIELD_TYPES.join(', ')}
171
+ (defaults to string if omitted)
164
172
 
165
173
  Examples:
166
- belt g scaffold post title:string body:text status:string
167
- belt g model user email:string name:string
174
+ belt g scaffold post title body:text status
175
+ belt g model user email name
168
176
  belt g controller comments
169
177
  belt g environment staging
170
178
  belt g frontend react
171
- belt g views post title:string body:text
179
+ belt g views post title body:text
172
180
 
173
181
  Run 'belt generate <generator> --help' for detailed help on a specific generator.
174
182
  HELP
@@ -197,50 +205,95 @@ module Belt
197
205
 
198
206
  puts "\nField Types:"
199
207
  puts " #{FIELD_TYPES.join(', ')}"
208
+ puts " (defaults to string if omitted)"
200
209
 
201
210
  puts "\nExamples:"
202
211
  info[:examples].each do |cmd, desc|
203
- puts " #{cmd}"
204
- puts " #{desc}" if desc
212
+ if desc
213
+ puts " #{cmd} — #{desc}"
214
+ else
215
+ puts " #{cmd}"
216
+ end
205
217
  end
206
218
 
207
219
  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
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
- @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
230
+ @singular_name = Belt::Inflector.singularize(@name)
231
+ @resource_name = Belt::Inflector.pluralize(@singular_name)
232
+ @class_name = Belt::Inflector.classify(@singular_name)
227
233
  end
228
234
 
229
235
  def generate
230
236
  case @generator
231
- when 'scaffold' then generate_resource
232
- when 'model' then generate_model
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
242
294
  inject_routes
243
295
  inject_schema
296
+ sync_tables
244
297
  generate_views_if_frontend
245
298
  puts "\n✓ Scaffold '#{@singular_name}' generated!"
246
299
  puts "\nFiles created/updated:"
@@ -252,6 +305,12 @@ module Belt
252
305
  puts " frontend/src/pages/#{@resource_name}/ (views)" if Dir.exist?('frontend/src')
253
306
  end
254
307
 
308
+ def generate_model_standalone
309
+ generate_model
310
+ inject_schema
311
+ sync_tables
312
+ end
313
+
255
314
  def generate_model
256
315
  dest = "lambda/models/#{@singular_name}.rb"
257
316
  write_template('model.rb.erb', dest)
@@ -272,8 +331,15 @@ module Belt
272
331
  tables_arg = @fields.any? ? ", tables: [:#{@resource_name}]" : ''
273
332
  resource_line = "resources :#{@resource_name}#{tables_arg}"
274
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
275
341
  # Replace the commented placeholder if it exists
276
- if content.include?('# resources :posts')
342
+ elsif content.include?('# resources :posts')
277
343
  content.sub!('# resources :posts', resource_line)
278
344
  else
279
345
  # Find the target namespace block and insert before its closing `end`
@@ -361,8 +427,12 @@ module Belt
361
427
 
362
428
  schema_block = " model :#{@singular_name} do\n#{field_lines.join("\n")}\n end\n"
363
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)
364
434
  # Replace commented-out block or insert before final end
365
- if content.match?(/^\s*#\s*model :/)
435
+ elsif content.match?(/^\s*#\s*model :/)
366
436
  content.gsub!(/^\s*#[^\n]*\n/, '')
367
437
  content.sub!(/^(end\s*\z)/m, "#{schema_block}\\1")
368
438
  else
@@ -373,6 +443,10 @@ module Belt
373
443
  puts " update #{schema_file}"
374
444
  end
375
445
 
446
+ def sync_tables
447
+ Belt::CLI::TablesCommand.sync_all_environments
448
+ end
449
+
376
450
  def write_template(template_name, dest_path)
377
451
  template_path = File.join(TEMPLATE_DIR, template_name)
378
452
  FileUtils.mkdir_p(File.dirname(dest_path))