belt 0.1.10 → 0.1.12

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: c48b3087bb87bb86b2111cc243cf57d889e8f3d94fb25f1b023e5ba3dabf4973
4
- data.tar.gz: 53b43e092412aaab0a0fae0e531d7d7ffb07c7a69e19c3e7e7480cf19753184f
3
+ metadata.gz: 892f04b0175a10793ba63f3cf65182b3d3e380ab75446984004283b8b8cf7377
4
+ data.tar.gz: 701534fd954f5d444783d3adb085d0582c4f9d1b92bf4518249e677add64c1b7
5
5
  SHA512:
6
- metadata.gz: 2443a472879d551084bd9f6a72b468e6dab20105ae84b3d906cbc33d42c8af6cc32229468300b00f9788a19c324b747f6cd0872d7d652c8d17e96a20cd2c95a6
7
- data.tar.gz: a6dd1cc9483792736335058a41b983df0380d8d48ee180b47e3a619a92d7d0a47fcd33af811cff20d7c451d1a783a8ccc49c3934dae60155c7aa6d039b8b7f27
6
+ metadata.gz: '0682c1d964357b0109baca143e7c43fb8688a375aa84af0ba3ba2c46fbf0d56932a011b8eef6a0027dcf6b289f3eb6bc4617bcecd8717812246133b107563233'
7
+ data.tar.gz: 68d1e8b3a4311ca6bd3be6dbfeb48594efeb4afaf42500d27b7076e0068a8b21c8cd73a1e41a70c4ee6338e910dab96aaa8fdba09683c922027574141049a899
data/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.1.11
6
+
7
+ ### Route DSL
8
+
9
+ - `resources` / `resource` honor `scope path:` (and nested path scopes).
10
+ Example: `scope path: "admin", auth: :cognito { resources :users }` → `/admin/users…` with Cognito auth.
11
+ - Nested `scope path:` segments stack (`a` then `b` → `a/b`) instead of replacing.
12
+ - `scope controller:` is applied to `resources` / `resource` the same way as verb routes.
13
+ - Controller inference for multi-segment resource paths joins segments (`/admin/users` → `admin/users`), matching nested-resource convention.
14
+
5
15
  ## 0.1.10
6
16
 
7
17
  ### Frontend env map
@@ -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
@@ -237,11 +237,11 @@ module Belt
237
237
  # Clean up trailing commas and empty arrays
238
238
  content.gsub!(/,(\s*\n\s*\]\.freeze)/, '\1')
239
239
 
240
- if content != original
241
- File.write(manifest_file, content)
242
- @updated << manifest_file
243
- puts " update #{manifest_file}"
244
- end
240
+ return unless content != original
241
+
242
+ File.write(manifest_file, content)
243
+ @updated << manifest_file
244
+ puts " update #{manifest_file}"
245
245
  end
246
246
 
247
247
  def remove_schema
@@ -257,11 +257,11 @@ module Belt
257
257
  # Clean up excessive blank lines
258
258
  content.gsub!(/\n{3,}/, "\n\n")
259
259
 
260
- if content != original
261
- File.write(schema_file, content)
262
- @updated << schema_file
263
- puts " update #{schema_file}"
264
- end
260
+ return unless content != original
261
+
262
+ File.write(schema_file, content)
263
+ @updated << schema_file
264
+ puts " update #{schema_file}"
265
265
  end
266
266
 
267
267
  def sync_tables
@@ -276,16 +276,16 @@ module Belt
276
276
  original = content.dup
277
277
 
278
278
  # Remove import lines for this resource's pages
279
- content.gsub!(/^import #{@class_name}\w* from '.\/pages\/#{@resource_name}\/.*'\n/, '')
279
+ content.gsub!(%r{^import #{@class_name}\w* from './pages/#{@resource_name}/.*'\n}, '')
280
280
 
281
281
  # Remove Route elements for this resource
282
- content.gsub!(/^\s*<Route path="\/#{@resource_name}.*?\/>\n/, '')
282
+ content.gsub!(%r{^\s*<Route path="/#{@resource_name}.*?/>\n}, '')
283
283
 
284
- if content != original
285
- File.write(app_jsx, content)
286
- @updated << app_jsx
287
- puts " update #{app_jsx}"
288
- end
284
+ return unless content != original
285
+
286
+ File.write(app_jsx, content)
287
+ @updated << app_jsx
288
+ puts " update #{app_jsx}"
289
289
  end
290
290
 
291
291
  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
@@ -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
@@ -139,14 +139,15 @@ module Belt
139
139
  errors << 'must only contain letters, numbers, and underscores' unless name.match?(/\A[a-zA-Z][a-zA-Z0-9_]*\z/)
140
140
  errors << 'must not start or end with an underscore' if name.match?(/\A_|_\z/)
141
141
  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)
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)
144
144
 
145
145
  return if errors.empty?
146
146
 
147
147
  puts "\n✗ Invalid #{generator} name '#{name}':"
148
148
  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."
149
+ puts "\nName must start with a letter, be at least 2 characters, " \
150
+ 'and contain only letters, numbers, and single underscores.'
150
151
  exit 1
151
152
  end
152
153
 
@@ -199,13 +200,13 @@ module Belt
199
200
  if info[:options].any?
200
201
  puts "\nOptions:"
201
202
  info[:options].each do |flag, desc|
202
- puts " %-20s %s" % [flag, desc]
203
+ puts format(' %<flag>-20s %<desc>s', flag: flag, desc: desc)
203
204
  end
204
205
  end
205
206
 
206
207
  puts "\nField Types:"
207
208
  puts " #{FIELD_TYPES.join(', ')}"
208
- puts " (defaults to string if omitted)"
209
+ puts ' (defaults to string if omitted)'
209
210
 
210
211
  puts "\nExamples:"
211
212
  info[:examples].each do |cmd, desc|
@@ -249,7 +250,9 @@ module Belt
249
250
  def check_resource_collision!
250
251
  conflicts = []
251
252
  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
+ if File.exist?("lambda/controllers/#{@app_name}/#{@resource_name}_controller.rb")
254
+ conflicts << "lambda/controllers/#{@app_name}/#{@resource_name}_controller.rb"
255
+ end
253
256
 
254
257
  schema_file = 'infrastructure/schema.tf.rb'
255
258
  if File.exist?(schema_file) && File.read(schema_file).match?(/^\s*model :#{Regexp.escape(@singular_name)}\b/)
@@ -348,7 +351,7 @@ module Belt
348
351
 
349
352
  if content.match?(namespace_pattern)
350
353
  content.sub!(namespace_pattern) do |match|
351
- indent = $1
354
+ indent = ::Regexp.last_match(1)
352
355
  # Insert the resource line before the namespace's closing `end`
353
356
  match.sub(/^(#{indent})end\z/m, "#{indent} #{resource_line}\n#{indent}end")
354
357
  end
@@ -357,7 +360,7 @@ module Belt
357
360
  single_ns_pattern = /^(\s*)namespace :\w+\b[^\n]*do\s*\n(.*?)^\1end/m
358
361
  if content.match?(single_ns_pattern)
359
362
  content.sub!(single_ns_pattern) do |match|
360
- indent = $1
363
+ indent = ::Regexp.last_match(1)
361
364
  match.sub(/^(#{indent})end\z/m, "#{indent} #{resource_line}\n#{indent}end")
362
365
  end
363
366
  else
@@ -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:"
@@ -14,7 +14,9 @@ module Belt
14
14
  non_param = segments.reject { |s| s.start_with?(':', '{') }
15
15
  return gateway.name if non_param.empty?
16
16
 
17
- return non_param.map { |s| s.gsub('-', '_') }.join('/') if route.resource? && nested_resource?(segments)
17
+ # Nested resources (/posts/{id}/comments) and scoped resources (/admin/users):
18
+ # join non-param segments → posts/comments, admin/users
19
+ return non_param.map { |s| s.gsub('-', '_') }.join('/') if route.resource? && non_param.length > 1
18
20
 
19
21
  # For non-resource routes with a single segment (e.g., post '/signup' in :onboarding),
20
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,9 +55,7 @@ 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
58
+ return DestroyCommand.run(args) if args.include?('--help') || args.include?('-h')
62
59
  elsif DestroyCommand::GENERATORS.include?(args.first)
63
60
  return DestroyCommand.run(args)
64
61
  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
@@ -308,7 +314,11 @@ module Belt
308
314
  previous_tables = @scope_tables
309
315
  previous_controller = @scope_controller
310
316
 
311
- @scope_prefix = options[:path] || @scope_prefix
317
+ # Nest path segments (Rails-style): scope path: "a" { scope path: "b" } → "a/b"
318
+ if options.key?(:path)
319
+ segment = options[:path].to_s.gsub(%r{^/|/$}, '')
320
+ @scope_prefix = @scope_prefix.to_s.empty? ? segment : "#{@scope_prefix}/#{segment}"
321
+ end
312
322
  @scope_module = options[:module] || @scope_module
313
323
  @scope_auth = options[:auth] || @scope_auth
314
324
  @scope_tables = (@scope_tables + Array(options[:tables] || [])).uniq
@@ -338,14 +348,64 @@ module Belt
338
348
  end
339
349
  end
340
350
 
341
- def resources(name, options = {}, &)
351
+ def resources(name, options = {}, &block)
342
352
  options = apply_scope_options(options)
343
- @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
344
382
  end
345
383
 
346
384
  def resource(name, options = {})
347
385
  options = apply_scope_options(options)
348
- @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
349
409
  end
350
410
 
351
411
  def lambda(name, &)
@@ -396,10 +456,35 @@ module Belt
396
456
  @scope_prefix.empty? ? path : "/#{@scope_prefix}#{path}"
397
457
  end
398
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
+
399
483
  def apply_scope_options(options)
400
484
  result = options.dup
401
485
  result[:auth] ||= @scope_auth if @scope_auth
402
486
  result[:lambda] ||= @scope_module if @scope_module
487
+ result[:controller] ||= @scope_controller if @scope_controller
403
488
  result[:tables] = (@scope_tables + Array(result[:tables] || [])).uniq if @scope_tables.any? || result[:tables]
404
489
  result
405
490
  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.10'
4
+ VERSION = '0.1.12'
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.10
4
+ version: 0.1.12
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stowzilla
@@ -161,7 +161,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
161
161
  requirements:
162
162
  - - ">="
163
163
  - !ruby/object:Gem::Version
164
- version: '3.0'
164
+ version: '3.3'
165
165
  required_rubygems_version: !ruby/object:Gem::Requirement
166
166
  requirements:
167
167
  - - ">="