belt 0.1.11 → 0.1.13

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: 68099232d7da2991436a263de28721bfc4e15822116f11a42cd2433588293b59
4
- data.tar.gz: ba091a54897cfd88b02e2ddf48750ca8a47ca3f4c298b7b4371be799e835550e
3
+ metadata.gz: b3834960aad8c883c22d15acec6a320d860bd8b90acb3c7c74751c59ce49b959
4
+ data.tar.gz: 29f96098a7ac79c0f3eb62edd9950a9d1e95e2f56b219b3fcd024b19f3b4daac
5
5
  SHA512:
6
- metadata.gz: 8e3d77c55c4297f2978486d9d0755ce45fda7f4a8886c372af0b211952502e5f5fde50c0d8881d4d5db39be4c3747a469825dfbd1b5d6f9be1120d441116600a
7
- data.tar.gz: 7226f0ce500b1d19a7bccf60f9f087573998f3589ae7b41dceba7f35289a71e7e4ea3250269b96b18ef823acff06bbd67916f8d39c1ecb025ce97fbb8288019e
6
+ metadata.gz: 597c85007b7e8838f77605ec31de2b20bac85bf29ec20de371b85f03dd4a9eb20abba6be12f5b8f701e2c36ee02377b3488efa00292060f36b8bc956ef04d8ed
7
+ data.tar.gz: 4c24a2f2c554244010ec246bc337194f6616ed04394ff8aeea4d5aabe91c97adeac7aaad76fbfa9a812fa35397fc46770c347ee590a5d307e2073ee2e811ff13
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.13
4
+
5
+ ### Generator Extension API
6
+
7
+ - Gems can now register generators by placing a file at `lib/belt/generators/<name>_generator.rb`.
8
+ - Discovered automatically via `Gem.loaded_specs` — no manual registration needed.
9
+ - Available as `belt generate <name>` / `belt destroy <name>`.
10
+ - Generator contract: `.run(args)` (required), `.destroy(args)` (optional), `.description` (optional).
11
+ - Help output includes gem-provided generators under "Gem Generators" section.
12
+
3
13
  ## Unreleased
4
14
 
5
15
  ## 0.1.11
@@ -157,10 +157,7 @@ module Belt
157
157
 
158
158
  # Final fallback: gem-embedded controllers under Belt:: namespace
159
159
  class_name = "#{controller_name.split(%r{[_/]}).map(&:capitalize).join}Controller"
160
- belt_class_name = "Belt::#{class_name}"
161
- if Belt.const_defined?(class_name, false)
162
- return Belt.const_get(class_name, false)
163
- end
160
+ return Belt.const_get(class_name, false) if Belt.const_defined?(class_name, false)
164
161
 
165
162
  raise Belt::ActionNotFound, "Controller not found: #{controller_name}"
166
163
  end
@@ -121,7 +121,8 @@ module Belt
121
121
  Dir.chdir(env_dir) do
122
122
  run_init
123
123
  run_plan
124
- return unless confirm_apply
124
+ return unless confirm_apply?
125
+
125
126
  run_apply
126
127
  end
127
128
 
@@ -157,6 +158,7 @@ module Belt
157
158
  dir = @infra_dir ? File.dirname(@infra_dir) : Dir.pwd
158
159
  while dir != '/'
159
160
  return dir if File.exist?(File.join(dir, 'Gemfile'))
161
+
160
162
  dir = File.dirname(dir)
161
163
  end
162
164
  Dir.pwd
@@ -188,9 +190,13 @@ module Belt
188
190
  stdout, status = Open3.capture2('aws', 'sts', 'get-caller-identity', '--output', 'json')
189
191
  unless status.success?
190
192
  abort "Error: AWS credentials not available.\n" \
191
- "Run `aws sso login` or configure AWS_PROFILE."
193
+ 'Run `aws sso login` or configure AWS_PROFILE.'
194
+ end
195
+ @aws_account = begin
196
+ JSON.parse(stdout)['Account']
197
+ rescue StandardError
198
+ nil
192
199
  end
193
- @aws_account = JSON.parse(stdout)['Account'] rescue nil
194
200
  end
195
201
 
196
202
  # Ensure Gemfile.lock doesn't have stale PATH references that will break
@@ -208,7 +214,8 @@ module Belt
208
214
  stale_path_gems = detect_stale_path_gems(gemfile_content, lockfile_content)
209
215
  return if stale_path_gems.empty?
210
216
 
211
- puts " 🔧 Fixing stale Gemfile.lock (#{stale_path_gems.join(', ')} referenced as PATH but Gemfile uses RubyGems)"
217
+ puts ' 🔧 Fixing stale Gemfile.lock ' \
218
+ "(#{stale_path_gems.join(', ')} referenced as PATH but Gemfile uses RubyGems)"
212
219
  Dir.chdir(@project_root) do
213
220
  success = system('bundle', 'lock', '--update', *stale_path_gems)
214
221
  unless success
@@ -250,7 +257,11 @@ module Belt
250
257
  output, status = Open3.capture2('terraform', 'output', '-json')
251
258
  return nil unless status.success?
252
259
 
253
- data = JSON.parse(output) rescue {}
260
+ data = begin
261
+ JSON.parse(output)
262
+ rescue StandardError
263
+ {}
264
+ end
254
265
 
255
266
  # Try lambda_functions output first
256
267
  if data['lambda_functions']
@@ -372,9 +383,10 @@ module Belt
372
383
  end
373
384
 
374
385
  # Remove non-essential files
375
- exts = %w[*.md *.rdoc *.txt *.c *.h *.o Makefile *.log CHANGELOG* HISTORY* LICENSE* README* .gitignore .travis.yml .rubocop.yml Rakefile]
386
+ exts = %w[*.md *.rdoc *.txt *.c *.h *.o Makefile *.log CHANGELOG* HISTORY* LICENSE* README* .gitignore
387
+ .travis.yml .rubocop.yml Rakefile]
376
388
  exts.each do |pattern|
377
- Dir.glob(File.join(vendor_dir, "**", pattern)).each do |f|
389
+ Dir.glob(File.join(vendor_dir, '**', pattern)).each do |f|
378
390
  File.delete(f) if File.file?(f)
379
391
  end
380
392
  end
@@ -409,12 +421,12 @@ module Belt
409
421
  '--output', 'json'
410
422
  )
411
423
 
412
- File.delete(zip_file) if File.exist?(zip_file)
424
+ FileUtils.rm_f(zip_file)
413
425
 
414
426
  unless status.success?
415
- abort "\n✗ Failed to update Lambda function code.\n" \
416
- " Function: #{function_name}\n" \
417
- " Check that the function exists and you have permissions."
427
+ abort "\n✗ Failed to update Lambda function code.\n " \
428
+ "Function: #{function_name}\n " \
429
+ 'Check that the function exists and you have permissions.'
418
430
  end
419
431
 
420
432
  # Wait for update to complete
@@ -454,7 +466,7 @@ module Belt
454
466
  puts ''
455
467
  end
456
468
 
457
- def confirm_apply
469
+ def confirm_apply?
458
470
  return true if @auto_approve
459
471
 
460
472
  if @env == 'prod' || @env == 'production'
@@ -479,7 +491,7 @@ module Belt
479
491
  end
480
492
 
481
493
  def cleanup_plan
482
- File.delete('tfplan') if File.exist?('tfplan')
494
+ FileUtils.rm_f('tfplan')
483
495
  end
484
496
 
485
497
  def deploy_frontend_if_exists
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'fileutils'
4
4
  require_relative 'app_detection'
5
+ require_relative 'generator_registry'
5
6
  require_relative 'tables_command'
6
7
  require_relative '../inflector'
7
8
 
@@ -20,13 +21,22 @@ module Belt
20
21
  exit 0
21
22
  end
22
23
 
23
- unless GENERATORS.include?(generator)
24
+ unless GENERATORS.include?(generator) || GeneratorRegistry.generator_names.include?(generator)
24
25
  puts "Unknown generator: '#{generator}'"
25
- puts "Available: #{GENERATORS.uniq.join(', ')}"
26
+ puts "Available: #{(GENERATORS + GeneratorRegistry.generator_names).uniq.join(', ')}"
26
27
  puts "\nRun 'belt destroy --help' for usage information."
27
28
  exit 1
28
29
  end
29
30
 
31
+ # Delegate to gem-provided generator if not a built-in
32
+ unless GENERATORS.include?(generator)
33
+ klass = GeneratorRegistry.find(generator)
34
+ return klass.destroy(args) if klass.respond_to?(:destroy)
35
+
36
+ puts "Generator '#{generator}' does not support destroy."
37
+ exit 1
38
+ end
39
+
30
40
  # Normalize: resource is an alias for scaffold
31
41
  generator = 'scaffold' if generator == 'resource'
32
42
 
@@ -237,11 +247,11 @@ module Belt
237
247
  # Clean up trailing commas and empty arrays
238
248
  content.gsub!(/,(\s*\n\s*\]\.freeze)/, '\1')
239
249
 
240
- if content != original
241
- File.write(manifest_file, content)
242
- @updated << manifest_file
243
- puts " update #{manifest_file}"
244
- end
250
+ return unless content != original
251
+
252
+ File.write(manifest_file, content)
253
+ @updated << manifest_file
254
+ puts " update #{manifest_file}"
245
255
  end
246
256
 
247
257
  def remove_schema
@@ -257,11 +267,11 @@ module Belt
257
267
  # Clean up excessive blank lines
258
268
  content.gsub!(/\n{3,}/, "\n\n")
259
269
 
260
- if content != original
261
- File.write(schema_file, content)
262
- @updated << schema_file
263
- puts " update #{schema_file}"
264
- end
270
+ return unless content != original
271
+
272
+ File.write(schema_file, content)
273
+ @updated << schema_file
274
+ puts " update #{schema_file}"
265
275
  end
266
276
 
267
277
  def sync_tables
@@ -276,16 +286,16 @@ module Belt
276
286
  original = content.dup
277
287
 
278
288
  # Remove import lines for this resource's pages
279
- content.gsub!(/^import #{@class_name}\w* from '.\/pages\/#{@resource_name}\/.*'\n/, '')
289
+ content.gsub!(%r{^import #{@class_name}\w* from './pages/#{@resource_name}/.*'\n}, '')
280
290
 
281
291
  # Remove Route elements for this resource
282
- content.gsub!(/^\s*<Route path="\/#{@resource_name}.*?\/>\n/, '')
292
+ content.gsub!(%r{^\s*<Route path="/#{@resource_name}.*?/>\n}, '')
283
293
 
284
- if content != original
285
- File.write(app_jsx, content)
286
- @updated << app_jsx
287
- puts " update #{app_jsx}"
288
- end
294
+ return unless content != original
295
+
296
+ File.write(app_jsx, content)
297
+ @updated << app_jsx
298
+ puts " update #{app_jsx}"
289
299
  end
290
300
 
291
301
  def print_summary
@@ -52,15 +52,15 @@ module Belt
52
52
 
53
53
  puts "\n✓ Environment '#{@env_name}' created!"
54
54
 
55
- unless @quiet
56
- puts "\nNext steps:"
57
- puts " cd #{dest_dir}"
58
- puts ' terraform init'
59
- puts ' terraform plan'
60
- puts ' terraform apply'
61
- puts "\nTo configure a custom domain, set `domain` in #{dest_dir}/terraform.tfvars:"
62
- puts " domain = \"myapp.com\""
63
- end
55
+ return if @quiet
56
+
57
+ puts "\nNext steps:"
58
+ puts " cd #{dest_dir}"
59
+ puts ' terraform init'
60
+ puts ' terraform plan'
61
+ puts ' terraform apply'
62
+ puts "\nTo configure a custom domain, set `domain` in #{dest_dir}/terraform.tfvars:"
63
+ puts ' domain = "myapp.com"'
64
64
  end
65
65
 
66
66
  private
@@ -68,7 +68,7 @@ module Belt
68
68
  puts "\n Installing npm dependencies..."
69
69
  success = system('npm', 'install', '--prefix', dest_dir, '--loglevel', 'error')
70
70
  if success
71
- puts " ✓ Dependencies installed"
71
+ puts ' ✓ Dependencies installed'
72
72
  else
73
73
  puts " ⚠ npm install failed — run `cd #{dest_dir} && npm install` manually"
74
74
  end
@@ -62,9 +62,7 @@ module Belt
62
62
 
63
63
  puts '🏗️ Building frontend...'
64
64
  env = frontend_build_env
65
- if env.any?
66
- puts " Injecting env: #{env.keys.sort.join(', ')}"
67
- end
65
+ puts " Injecting env: #{env.keys.sort.join(', ')}" if env.any?
68
66
  run!(env, 'npm', 'run', 'build', chdir: 'frontend')
69
67
  end
70
68
 
@@ -112,7 +112,7 @@ module Belt
112
112
  def load_map
113
113
  return DEFAULT_MAP.dup if @map_path.nil?
114
114
 
115
- raw = YAML.safe_load(File.read(@map_path), aliases: false)
115
+ raw = YAML.safe_load_file(@map_path, aliases: false)
116
116
  unless raw.is_a?(Hash) && raw.any?
117
117
  abort "Error: frontend env map #{@map_path} must be a non-empty YAML mapping " \
118
118
  '(e.g. VITE_API_URL: api_url).'
@@ -124,7 +124,7 @@ module Belt
124
124
  next if k.empty? || v.empty?
125
125
 
126
126
  hash[k] = v
127
- end.tap do |parsed|
127
+ end.tap do |parsed| # rubocop:disable Style/MultilineBlockChain
128
128
  abort "Error: frontend env map #{@map_path} has no valid entries." if parsed.empty?
129
129
  end
130
130
  rescue Psych::SyntaxError => e
@@ -181,7 +181,7 @@ module Belt
181
181
  return str if str.empty?
182
182
  return str unless str.match?(/[\s\#"'\\$`]/)
183
183
 
184
- %("#{str.gsub('\\', '\\\\').gsub('"', '\\"')}")
184
+ %("#{str.gsub('\\') { '\\\\' }.gsub('"') { '\\"' }}")
185
185
  end
186
186
  end
187
187
  end
@@ -20,7 +20,7 @@ module Belt
20
20
  new.run
21
21
  end
22
22
 
23
- def initialize(env = nil, quiet: false)
23
+ def initialize(_env = nil, quiet: false)
24
24
  @app_name = detect_app_name
25
25
  @quiet = quiet
26
26
  end
@@ -32,7 +32,7 @@ module Belt
32
32
 
33
33
  puts "\n✓ Frontend infrastructure generated in #{MODULE_DIR}!"
34
34
  puts "\nRun `belt deploy` to create the S3 bucket and CloudFront distribution."
35
- puts "Then `belt deploy frontend` to build and deploy."
35
+ puts 'Then `belt deploy frontend` to build and deploy.'
36
36
  end
37
37
 
38
38
  private
@@ -41,7 +41,7 @@ module Belt
41
41
  return if Dir.exist?(MODULE_DIR)
42
42
 
43
43
  abort "Error: Module directory not found at #{MODULE_DIR}/.\n" \
44
- "Run `belt new` to create a project with the correct structure."
44
+ 'Run `belt new` to create a project with the correct structure.'
45
45
  end
46
46
 
47
47
  def generate_frontend_tf
@@ -7,6 +7,7 @@ require_relative 'environment_command'
7
7
  require_relative 'frontend_command'
8
8
  require_relative 'tables_command'
9
9
  require_relative 'views_command'
10
+ require_relative 'generator_registry'
10
11
  require_relative '../inflector'
11
12
 
12
13
  module Belt
@@ -84,13 +85,19 @@ module Belt
84
85
  exit 0
85
86
  end
86
87
 
87
- unless GENERATORS.include?(generator)
88
+ unless GENERATORS.include?(generator) || GeneratorRegistry.generator_names.include?(generator)
88
89
  puts "Unknown generator: '#{generator}'"
89
- puts "Available generators: #{(GENERATORS - ['resource']).join(', ')}"
90
+ puts "Available generators: #{all_generator_names.join(', ')}"
90
91
  puts "\nRun 'belt generate --help' for usage information."
91
92
  exit 1
92
93
  end
93
94
 
95
+ # Delegate to gem-provided generator if not a built-in
96
+ unless GENERATORS.include?(generator)
97
+ klass = GeneratorRegistry.find(generator)
98
+ return klass.run(args)
99
+ end
100
+
94
101
  # Normalize: resource is an alias for scaffold
95
102
  generator = 'scaffold' if generator == 'resource'
96
103
 
@@ -131,6 +138,10 @@ module Belt
131
138
  belt setup environment frontend views routes
132
139
  ].freeze
133
140
 
141
+ def self.all_generator_names
142
+ ((GENERATORS - ['resource']) + GeneratorRegistry.generator_names).uniq
143
+ end
144
+
134
145
  def self.validate_resource_name!(name, generator)
135
146
  errors = []
136
147
 
@@ -139,18 +150,21 @@ module Belt
139
150
  errors << 'must only contain letters, numbers, and underscores' unless name.match?(/\A[a-zA-Z][a-zA-Z0-9_]*\z/)
140
151
  errors << 'must not start or end with an underscore' if name.match?(/\A_|_\z/)
141
152
  errors << 'must not contain consecutive underscores' if name.include?('__')
142
- errors << "is a reserved name" if RESERVED_NAMES.include?(Belt::Inflector.singularize(name.downcase))
143
- errors << "is a reserved name" if RESERVED_NAMES.include?(name.downcase)
153
+ errors << 'is a reserved name' if RESERVED_NAMES.include?(Belt::Inflector.singularize(name.downcase))
154
+ errors << 'is a reserved name' if RESERVED_NAMES.include?(name.downcase)
144
155
 
145
156
  return if errors.empty?
146
157
 
147
158
  puts "\n✗ Invalid #{generator} name '#{name}':"
148
159
  errors.uniq.each { |e| puts " - #{e}" }
149
- puts "\nName must start with a letter, be at least 2 characters, and contain only letters, numbers, and single underscores."
160
+ puts "\nName must start with a letter, be at least 2 characters, " \
161
+ 'and contain only letters, numbers, and single underscores.'
150
162
  exit 1
151
163
  end
152
164
 
153
165
  def self.print_generate_help
166
+ gem_generators = GeneratorRegistry.discovered_generators
167
+
154
168
  puts <<~HELP
155
169
  Usage: belt generate <generator> <name> [field:type ...] [options]
156
170
  belt g <generator> <name> [field:type ...] [options]
@@ -166,6 +180,18 @@ module Belt
166
180
  Aliases:
167
181
  resource Same as scaffold
168
182
 
183
+ HELP
184
+
185
+ if gem_generators.any?
186
+ puts ' Gem Generators:'
187
+ gem_generators.each do |name, klass|
188
+ desc = klass.respond_to?(:description) ? klass.description : "Generate #{name} (from gem)"
189
+ puts format(' %<name>-14s %<desc>s', name: name, desc: desc)
190
+ end
191
+ puts
192
+ end
193
+
194
+ puts <<~HELP
169
195
  Field Types:
170
196
  #{FIELD_TYPES.join(', ')}
171
197
  (defaults to string if omitted)
@@ -199,13 +225,13 @@ module Belt
199
225
  if info[:options].any?
200
226
  puts "\nOptions:"
201
227
  info[:options].each do |flag, desc|
202
- puts " %-20s %s" % [flag, desc]
228
+ puts format(' %<flag>-20s %<desc>s', flag: flag, desc: desc)
203
229
  end
204
230
  end
205
231
 
206
232
  puts "\nField Types:"
207
233
  puts " #{FIELD_TYPES.join(', ')}"
208
- puts " (defaults to string if omitted)"
234
+ puts ' (defaults to string if omitted)'
209
235
 
210
236
  puts "\nExamples:"
211
237
  info[:examples].each do |cmd, desc|
@@ -249,7 +275,9 @@ module Belt
249
275
  def check_resource_collision!
250
276
  conflicts = []
251
277
  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")
278
+ if File.exist?("lambda/controllers/#{@app_name}/#{@resource_name}_controller.rb")
279
+ conflicts << "lambda/controllers/#{@app_name}/#{@resource_name}_controller.rb"
280
+ end
253
281
 
254
282
  schema_file = 'infrastructure/schema.tf.rb'
255
283
  if File.exist?(schema_file) && File.read(schema_file).match?(/^\s*model :#{Regexp.escape(@singular_name)}\b/)
@@ -348,7 +376,7 @@ module Belt
348
376
 
349
377
  if content.match?(namespace_pattern)
350
378
  content.sub!(namespace_pattern) do |match|
351
- indent = $1
379
+ indent = ::Regexp.last_match(1)
352
380
  # Insert the resource line before the namespace's closing `end`
353
381
  match.sub(/^(#{indent})end\z/m, "#{indent} #{resource_line}\n#{indent}end")
354
382
  end
@@ -357,7 +385,7 @@ module Belt
357
385
  single_ns_pattern = /^(\s*)namespace :\w+\b[^\n]*do\s*\n(.*?)^\1end/m
358
386
  if content.match?(single_ns_pattern)
359
387
  content.sub!(single_ns_pattern) do |match|
360
- indent = $1
388
+ indent = ::Regexp.last_match(1)
361
389
  match.sub(/^(#{indent})end\z/m, "#{indent} #{resource_line}\n#{indent}end")
362
390
  end
363
391
  else
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Belt
4
+ module CLI
5
+ # Discovers and manages generators provided by external gems.
6
+ #
7
+ # Gems register generators by placing a file at:
8
+ # lib/belt/generators/<name>_generator.rb
9
+ #
10
+ # Each generator class must:
11
+ # - Be named <Name>Generator (e.g., MessagingGenerator)
12
+ # - Live in the Belt::Generators module
13
+ # - Implement .run(args) for generation
14
+ # - Implement .destroy(args) for teardown (optional)
15
+ # - Implement .description for help text (optional)
16
+ #
17
+ # Example gem structure:
18
+ # belt-messaging/
19
+ # lib/
20
+ # belt/
21
+ # generators/
22
+ # messaging_generator.rb
23
+ #
24
+ # The generator is then available as:
25
+ # belt generate messaging
26
+ # belt destroy messaging
27
+ #
28
+ module GeneratorRegistry
29
+ GENERATOR_PATH = 'lib/belt/generators'
30
+
31
+ class << self
32
+ # Returns a hash of { "name" => generator_class } for all discovered generators.
33
+ def discovered_generators
34
+ @discovered_generators ||= discover_all
35
+ end
36
+
37
+ # Returns just the names of discovered generators.
38
+ def generator_names
39
+ discovered_generators.keys
40
+ end
41
+
42
+ # Loads and returns the generator class for a given name.
43
+ # Returns nil if not found.
44
+ def find(name)
45
+ discovered_generators[name]
46
+ end
47
+
48
+ # Reset cache (useful in tests)
49
+ def reset!
50
+ @discovered_generators = nil
51
+ end
52
+
53
+ private
54
+
55
+ def discover_all
56
+ generators = {}
57
+
58
+ Gem.loaded_specs.each_value do |spec|
59
+ generators_dir = File.join(spec.gem_dir, GENERATOR_PATH)
60
+ next unless File.directory?(generators_dir)
61
+
62
+ Dir.glob(File.join(generators_dir, '*_generator.rb')).each do |file|
63
+ name = File.basename(file, '.rb').sub(/_generator\z/, '')
64
+ klass = load_generator_class(file, name)
65
+ generators[name] = klass if klass
66
+ end
67
+ end
68
+
69
+ generators
70
+ end
71
+
72
+ def load_generator_class(file, name)
73
+ require file
74
+
75
+ class_name = "#{classify(name)}Generator"
76
+ Belt::Generators.const_get(class_name)
77
+ rescue LoadError, NameError => e
78
+ warn "[Belt] Warning: Failed to load generator '#{name}' from #{file}: #{e.message}"
79
+ nil
80
+ end
81
+
82
+ def classify(name)
83
+ name.split('_').map(&:capitalize).join
84
+ end
85
+ end
86
+ end
87
+ end
88
+
89
+ # Namespace for generator classes provided by external gems
90
+ module Generators
91
+ end
92
+ end
@@ -185,7 +185,7 @@ module Belt
185
185
  if aws_configured?
186
186
  puts "\n Setting up Terraform state bucket..."
187
187
  begin
188
- Belt::CLI::SetupCommand.new(["--bucket", @resolved_bucket]).run_state_setup
188
+ Belt::CLI::SetupCommand.new(['--bucket', @resolved_bucket]).run_state_setup
189
189
  @state_setup_succeeded = true
190
190
  rescue SystemExit
191
191
  puts ' ⚠ State bucket setup encountered an issue — run `belt setup state` to retry.'
@@ -195,7 +195,7 @@ module Belt
195
195
  puts "\n State bucket: #{@resolved_bucket}"
196
196
  puts " State keys: #{s3_safe_name(@app_name)}/<env>/terraform.tfstate"
197
197
  if @aws_error&.include?('ForbiddenException') || @aws_error&.include?('AccessDenied')
198
- puts " ⚠ AWS credentials found but access denied — check your profile/role configuration."
198
+ puts ' ⚠ AWS credentials found but access denied — check your profile/role configuration.'
199
199
  puts " #{@aws_error}" if @aws_error
200
200
  else
201
201
  puts ' ⚠ AWS credentials not detected — skipping state bucket creation.'
@@ -209,7 +209,11 @@ module Belt
209
209
  def aws_configured?
210
210
  output, status = Open3.capture2e('aws', 'sts', 'get-caller-identity')
211
211
  if status.success?
212
- data = JSON.parse(output) rescue {}
212
+ data = begin
213
+ JSON.parse(output)
214
+ rescue StandardError
215
+ {}
216
+ end
213
217
  @aws_account_id = data['Account']
214
218
  true
215
219
  else
@@ -284,7 +288,7 @@ module Belt
284
288
  puts ' → Edit → paste the 4 values from step 1.'
285
289
  puts ''
286
290
  puts ' 3. Wait for propagation (usually 5–30 min, can take up to 48h).'
287
- puts ' Verify: dig +short NS #{@domain}'
291
+ puts " Verify: dig +short NS #{@domain}"
288
292
  puts ' ─────────────────────────────────────────────────────────────────'
289
293
  else
290
294
  puts "\n To add a custom domain later, set `domain` in infrastructure/<env>/terraform.tfvars:"
@@ -16,9 +16,7 @@ module Belt
16
16
 
17
17
  # Nested resources (/posts/{id}/comments) and scoped resources (/admin/users):
18
18
  # join non-param segments → posts/comments, admin/users
19
- if route.resource? && non_param.length > 1
20
- return non_param.map { |s| s.gsub('-', '_') }.join('/')
21
- end
19
+ return non_param.map { |s| s.gsub('-', '_') }.join('/') if route.resource? && non_param.length > 1
22
20
 
23
21
  # For non-resource routes with a single segment (e.g., post '/signup' in :onboarding),
24
22
  # the segment is the action name, not the controller. Use the gateway name as controller.
@@ -94,9 +94,7 @@ module Belt
94
94
  else
95
95
  puts ' Backend is serverless — deploy with `belt deploy` to set up AWS resources.'
96
96
  end
97
- if build_env.any?
98
- puts " Env: #{build_env.keys.sort.join(', ')}"
99
- end
97
+ puts " Env: #{build_env.keys.sort.join(', ')}" if build_env.any?
100
98
  puts ''
101
99
 
102
100
  open_browser_later if @open_browser
@@ -121,24 +119,20 @@ module Belt
121
119
  end
122
120
 
123
121
  def run_welcome_server
122
+ puts "🚀 Serving Belt welcome page on http://localhost:#{@port}"
124
123
  if @api_url
125
- puts "🚀 Serving Belt welcome page on http://localhost:#{@port}"
126
124
  puts " Backend API: #{@api_url}"
127
- puts ''
128
- puts ' Tip: Run `belt generate frontend react` to scaffold a frontend app.'
129
- puts ''
130
125
  else
131
- puts "🚀 Serving Belt welcome page on http://localhost:#{@port}"
132
126
  puts ' No deployment detected — showing pre-deploy welcome page.'
133
127
  puts ' Backend is serverless — deploy with `belt deploy` to set up AWS resources.'
134
- puts ''
135
- puts ' Tip: Run `belt generate frontend react` to scaffold a frontend app.'
136
- puts ''
137
128
  end
129
+ puts ''
130
+ puts ' Tip: Run `belt generate frontend react` to scaffold a frontend app.'
131
+ puts ''
138
132
 
139
133
  require 'webrick'
140
134
 
141
- server = WEBrick::HTTPServer.new(Port: @port, Logger: WEBrick::Log.new('/dev/null'), AccessLog: [])
135
+ server = WEBrick::HTTPServer.new(Port: @port, Logger: WEBrick::Log.new(File::NULL), AccessLog: [])
142
136
 
143
137
  server.mount_proc '/' do |_req, res|
144
138
  res['Content-Type'] = 'text/html; charset=utf-8'
@@ -53,7 +53,7 @@ module Belt
53
53
  def run_state_setup
54
54
  unless aws_configured?
55
55
  if @aws_error&.include?('ForbiddenException') || @aws_error&.include?('AccessDenied')
56
- puts "✗ AWS credentials found but access denied — check your profile/role configuration."
56
+ puts '✗ AWS credentials found but access denied — check your profile/role configuration.'
57
57
  puts " #{@aws_error}"
58
58
  else
59
59
  puts '✗ AWS credentials not configured. Set AWS_PROFILE or configure aws sso login.'
@@ -139,11 +139,7 @@ module Belt
139
139
  end
140
140
 
141
141
  def resolve_bucket_name
142
- if @custom_bucket
143
- @custom_bucket
144
- else
145
- 'belt-terraform-state'
146
- end
142
+ @custom_bucket || 'belt-terraform-state'
147
143
  end
148
144
 
149
145
  # --- Interactive selection ---
@@ -192,7 +188,11 @@ module Belt
192
188
  def aws_configured?
193
189
  output, status = Open3.capture2e('aws', 'sts', 'get-caller-identity')
194
190
  if status.success?
195
- data = JSON.parse(output) rescue {}
191
+ data = begin
192
+ JSON.parse(output)
193
+ rescue StandardError
194
+ {}
195
+ end
196
196
  @aws_account_id = data['Account']
197
197
  true
198
198
  else
@@ -201,9 +201,7 @@ module Belt
201
201
  end
202
202
  end
203
203
 
204
- def aws_account_id
205
- @aws_account_id
206
- end
204
+ attr_reader :aws_account_id
207
205
 
208
206
  def bucket_exists?(bucket)
209
207
  output, status = Open3.capture2e('aws', 's3api', 'head-bucket', '--bucket', bucket)
@@ -65,10 +65,10 @@ module Belt
65
65
  end
66
66
  return if Dir.exist?(MODULE_DIR)
67
67
 
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
68
+ return if @quiet
69
+
70
+ abort "Error: Module directory not found at #{MODULE_DIR}/.\n" \
71
+ 'Run `belt new` to create a project with the correct structure.'
72
72
  end
73
73
 
74
74
  def parse_schema
data/lib/belt/cli.rb CHANGED
@@ -35,7 +35,6 @@ module Belt
35
35
  %w[version --version -v] => ->(_args) { puts "Belt #{Belt::VERSION}" }
36
36
  }.freeze
37
37
 
38
-
39
38
  COMMANDS = COMMANDS_DEFINITION.each_with_object({}) do |(keys, handler), hash|
40
39
  Array(keys).each { |key| hash[key] = handler }
41
40
  end.freeze
@@ -56,10 +55,8 @@ module Belt
56
55
  if args.empty? || args.first =~ /\A-/
57
56
  # belt destroy --help → DestroyCommand help
58
57
  # belt destroy (no args) → terraform destroy (needs env)
59
- if args.include?('--help') || args.include?('-h')
60
- return DestroyCommand.run(args)
61
- end
62
- elsif DestroyCommand::GENERATORS.include?(args.first)
58
+ return DestroyCommand.run(args) if args.include?('--help') || args.include?('-h')
59
+ elsif DestroyCommand::GENERATORS.include?(args.first) || GeneratorRegistry.generator_names.include?(args.first)
63
60
  return DestroyCommand.run(args)
64
61
  end
65
62
  end
@@ -34,11 +34,11 @@ module Belt
34
34
  end
35
35
 
36
36
  def welcome_css
37
- @welcome_css_content ||= File.read(File.join(ASSETS_DIR, 'welcome.css'))
37
+ @welcome_css ||= File.read(File.join(ASSETS_DIR, 'welcome.css'))
38
38
  end
39
39
 
40
40
  def background_image_base64
41
- @background_image_content ||= Base64.strict_encode64(
41
+ @background_image_base64 ||= Base64.strict_encode64(
42
42
  File.binread(File.join(ASSETS_DIR, 'belt-default.jpg'))
43
43
  )
44
44
  end
@@ -44,14 +44,14 @@ module Belt
44
44
 
45
45
  # Auto-load all models (application_record first, then the rest)
46
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
47
+ return unless File.directory?(models_dir)
51
48
 
52
- require app_record if app_record
53
- rest.each { |f| require f }
54
- end
49
+ all_models = Dir[File.join(models_dir, '**', '*.rb')]
50
+ app_record = all_models.find { |f| File.basename(f) == 'application_record.rb' }
51
+ rest = all_models - [app_record].compact
52
+
53
+ require app_record if app_record
54
+ rest.each { |f| require f }
55
55
  end
56
56
 
57
57
  # API Gateway Lambda handler.
@@ -51,12 +51,14 @@ module Belt
51
51
  end
52
52
 
53
53
  class NestedResourceBuilder
54
- def initialize(gateway, prefix, collection_prefix, inherited_tables: [], inherited_auth: nil)
54
+ def initialize(gateway, prefix, collection_prefix, inherited_tables: [], inherited_auth: nil, # rubocop:disable Metrics/ParameterLists
55
+ inherited_controller: nil)
55
56
  @gateway = gateway
56
57
  @prefix = prefix
57
58
  @collection_prefix = collection_prefix
58
59
  @inherited_tables = inherited_tables
59
60
  @inherited_auth = inherited_auth
61
+ @inherited_controller = inherited_controller
60
62
  end
61
63
 
62
64
  def resources(name, options = {})
@@ -85,12 +87,13 @@ module Belt
85
87
  end
86
88
 
87
89
  def member(&)
88
- MemberCollectionBuilder.new(@gateway, @prefix, @inherited_tables, @inherited_auth).instance_eval(&)
90
+ MemberCollectionBuilder.new(@gateway, @prefix, @inherited_tables, @inherited_auth,
91
+ @inherited_controller).instance_eval(&)
89
92
  end
90
93
 
91
94
  def collection(&)
92
95
  MemberCollectionBuilder.new(@gateway, @collection_prefix, @inherited_tables,
93
- @inherited_auth).instance_eval(&)
96
+ @inherited_auth, @inherited_controller).instance_eval(&)
94
97
  end
95
98
 
96
99
  %i[get post put delete patch].each do |method|
@@ -111,16 +114,18 @@ module Belt
111
114
  result[:tables] = (@inherited_tables + explicit_tables).uniq
112
115
  end
113
116
  result[:auth] ||= @inherited_auth if @inherited_auth
117
+ result[:controller] ||= @inherited_controller if @inherited_controller
114
118
  result
115
119
  end
116
120
  end
117
121
 
118
122
  class MemberCollectionBuilder
119
- def initialize(gateway, prefix, inherited_tables, inherited_auth)
123
+ def initialize(gateway, prefix, inherited_tables, inherited_auth, inherited_controller = nil)
120
124
  @gateway = gateway
121
125
  @prefix = prefix
122
126
  @inherited_tables = inherited_tables
123
127
  @inherited_auth = inherited_auth
128
+ @inherited_controller = inherited_controller
124
129
  end
125
130
 
126
131
  %i[get post put delete patch].each do |method|
@@ -140,6 +145,7 @@ module Belt
140
145
  result[:tables] = (@inherited_tables + explicit_tables).uniq
141
146
  end
142
147
  result[:auth] ||= @inherited_auth if @inherited_auth
148
+ result[:controller] ||= @inherited_controller if @inherited_controller
143
149
  result
144
150
  end
145
151
  end
@@ -174,22 +180,20 @@ module Belt
174
180
  resource_name = name.to_s
175
181
  singular = singularize(resource_name)
176
182
  param_name = options[:param] || "#{singular}_id"
177
- base_path = resource_base_path(resource_name, options)
178
- options = options.except(:path_prefix)
179
183
  options = auto_infer_tables(resource_name, options)
180
184
  resource_options = options.merge(route_type: :resources)
181
185
  actions = determine_actions(options)
182
186
 
183
- add_route(:get, base_path, resource_options) if actions.include?(:index)
184
- add_route(:post, base_path, resource_options) if actions.include?(:create)
185
- add_route(:get, "#{base_path}/{#{param_name}}", resource_options) if actions.include?(:show)
186
- add_route(:put, "#{base_path}/{#{param_name}}", resource_options) if actions.include?(:update)
187
- add_route(:delete, "#{base_path}/{#{param_name}}", resource_options) if actions.include?(:destroy)
187
+ add_route(:get, "/#{resource_name}", resource_options) if actions.include?(:index)
188
+ add_route(:post, "/#{resource_name}", resource_options) if actions.include?(:create)
189
+ add_route(:get, "/#{resource_name}/{#{param_name}}", resource_options) if actions.include?(:show)
190
+ add_route(:put, "/#{resource_name}/{#{param_name}}", resource_options) if actions.include?(:update)
191
+ add_route(:delete, "/#{resource_name}/{#{param_name}}", resource_options) if actions.include?(:destroy)
188
192
 
189
193
  return unless block_given?
190
194
 
191
- collection_prefix = base_path
192
- member_prefix = "#{base_path}/{#{param_name}}"
195
+ collection_prefix = "/#{resource_name}"
196
+ member_prefix = "/#{resource_name}/{#{param_name}}"
193
197
  resource_tables = Array(options[:tables] || [])
194
198
  inherited_tables = (@default_tables + resource_tables).uniq
195
199
  inherited_auth = options[:auth] || @default_auth
@@ -201,25 +205,17 @@ module Belt
201
205
 
202
206
  def resource(name, options = {})
203
207
  resource_name = name.to_s
204
- base_path = resource_base_path(resource_name, options)
205
- options = options.except(:path_prefix)
206
208
  actions = determine_actions(options, default: %i[show update destroy])
207
209
  resource_options = options.merge(route_type: :resource)
208
210
 
209
- add_route(:get, base_path, resource_options) if actions.include?(:show)
210
- add_route(:put, base_path, resource_options) if actions.include?(:update)
211
- add_route(:delete, base_path, resource_options) if actions.include?(:destroy)
212
- add_route(:post, base_path, resource_options) if actions.include?(:create)
211
+ add_route(:get, "/#{resource_name}", resource_options) if actions.include?(:show)
212
+ add_route(:put, "/#{resource_name}", resource_options) if actions.include?(:update)
213
+ add_route(:delete, "/#{resource_name}", resource_options) if actions.include?(:destroy)
214
+ add_route(:post, "/#{resource_name}", resource_options) if actions.include?(:create)
213
215
  end
214
216
 
215
217
  private
216
218
 
217
- # Builds "/users" or "/admin/users" when path_prefix is set (from scope path:).
218
- def resource_base_path(resource_name, options)
219
- prefix = options[:path_prefix].to_s.gsub(%r{^/|/$}, '')
220
- prefix.empty? ? "/#{resource_name}" : "/#{prefix}/#{resource_name}"
221
- end
222
-
223
219
  def add_route(method, path, options = {})
224
220
  lambda_to_use = options[:lambda] || @current_lambda_context || @default_lambda
225
221
  route_tables = Array(options[:tables] || [])
@@ -352,16 +348,64 @@ module Belt
352
348
  end
353
349
  end
354
350
 
355
- def resources(name, options = {}, &)
351
+ def resources(name, options = {}, &block)
356
352
  options = apply_scope_options(options)
357
- options = options.merge(path_prefix: @scope_prefix) unless @scope_prefix.to_s.empty?
358
- @gateway.resources(name, options, &)
353
+
354
+ if @scope_prefix.empty?
355
+ @gateway.resources(name, options, &block)
356
+ else
357
+ # When inside a scope, generate routes with the prefix applied.
358
+ # Also set the controller explicitly so inference resolves correctly
359
+ # (e.g., scope "admin" + resources :users → controller "admin/users").
360
+ resource_name = name.to_s
361
+ singular = Belt::Inflector.singularize(resource_name)
362
+ param_name = options[:param] || "#{singular}_id"
363
+ controller = "#{@scope_prefix}/#{resource_name}"
364
+ resource_options = options.merge(route_type: :resources, controller: controller)
365
+ actions = determine_scoped_actions(options)
366
+
367
+ add_scoped_resource_routes(resource_name, param_name, resource_options, actions)
368
+
369
+ if block
370
+ collection_prefix = build_path("/#{resource_name}")
371
+ member_prefix = build_path("/#{resource_name}/{#{param_name}}")
372
+ resource_tables = Array(options[:tables] || [])
373
+ inherited_tables = (@gateway.default_tables + resource_tables).uniq
374
+ inherited_auth = options[:auth] || @gateway.default_auth
375
+ nested_builder = NestedResourceBuilder.new(@gateway, member_prefix, collection_prefix,
376
+ inherited_tables: inherited_tables,
377
+ inherited_auth: inherited_auth,
378
+ inherited_controller: controller)
379
+ nested_builder.instance_eval(&block)
380
+ end
381
+ end
359
382
  end
360
383
 
361
384
  def resource(name, options = {})
362
385
  options = apply_scope_options(options)
363
- options = options.merge(path_prefix: @scope_prefix) unless @scope_prefix.to_s.empty?
364
- @gateway.resource(name, options)
386
+
387
+ if @scope_prefix.empty?
388
+ @gateway.resource(name, options)
389
+ else
390
+ resource_name = name.to_s
391
+ controller = "#{@scope_prefix}/#{resource_name}"
392
+ resource_options = options.merge(route_type: :resource, controller: controller)
393
+ actions = determine_scoped_actions(options, default: %i[show update destroy create])
394
+
395
+ @gateway.send(:add_route, :get, build_path("/#{resource_name}"), resource_options) if actions.include?(:show)
396
+ if actions.include?(:update)
397
+ @gateway.send(:add_route, :put, build_path("/#{resource_name}"),
398
+ resource_options)
399
+ end
400
+ if actions.include?(:destroy)
401
+ @gateway.send(:add_route, :delete, build_path("/#{resource_name}"),
402
+ resource_options)
403
+ end
404
+ if actions.include?(:create)
405
+ @gateway.send(:add_route, :post, build_path("/#{resource_name}"),
406
+ resource_options)
407
+ end
408
+ end
365
409
  end
366
410
 
367
411
  def lambda(name, &)
@@ -412,6 +456,30 @@ module Belt
412
456
  @scope_prefix.empty? ? path : "/#{@scope_prefix}#{path}"
413
457
  end
414
458
 
459
+ def determine_scoped_actions(options, default: %i[index create show update destroy])
460
+ if options[:only]
461
+ Array(options[:only])
462
+ elsif options[:except]
463
+ default - Array(options[:except])
464
+ else
465
+ default
466
+ end
467
+ end
468
+
469
+ def add_scoped_resource_routes(resource_name, param_name, resource_options, actions)
470
+ @gateway.send(:add_route, :get, build_path("/#{resource_name}"), resource_options) if actions.include?(:index)
471
+ @gateway.send(:add_route, :post, build_path("/#{resource_name}"), resource_options) if actions.include?(:create)
472
+ if actions.include?(:show)
473
+ @gateway.send(:add_route, :get, build_path("/#{resource_name}/{#{param_name}}"), resource_options)
474
+ end
475
+ if actions.include?(:update)
476
+ @gateway.send(:add_route, :put, build_path("/#{resource_name}/{#{param_name}}"), resource_options)
477
+ end
478
+ return unless actions.include?(:destroy)
479
+
480
+ @gateway.send(:add_route, :delete, build_path("/#{resource_name}/{#{param_name}}"), resource_options)
481
+ end
482
+
415
483
  def apply_scope_options(options)
416
484
  result = options.dup
417
485
  result[:auth] ||= @scope_auth if @scope_auth
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.11'
4
+ VERSION = '0.1.13'
5
5
  end
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.11
4
+ version: 0.1.13
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stowzilla
@@ -82,6 +82,7 @@ files:
82
82
  - lib/belt/cli/frontend_env_map.rb
83
83
  - lib/belt/cli/frontend_setup_command.rb
84
84
  - lib/belt/cli/generate_command.rb
85
+ - lib/belt/cli/generator_registry.rb
85
86
  - lib/belt/cli/new_command.rb
86
87
  - lib/belt/cli/routes_command.rb
87
88
  - lib/belt/cli/routes_command/route_inference.rb
@@ -161,7 +162,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
161
162
  requirements:
162
163
  - - ">="
163
164
  - !ruby/object:Gem::Version
164
- version: '3.0'
165
+ version: '3.3'
165
166
  required_rubygems_version: !ruby/object:Gem::Requirement
166
167
  requirements:
167
168
  - - ">="