belt 0.6.3 → 0.6.5

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: 8a728d2756664cff1204c7926b8db4cbe7d327e1aacf6d544b9d39db2e2e258c
4
- data.tar.gz: 5b4eb1d119a8343abbdfe5fc329f167546bc963ecb7ef415d0c54b5c049cf084
3
+ metadata.gz: 39a9c396d05b2acfc5c69adc875f0050beb9395a2dc9f31dd4ac2d2221e011db
4
+ data.tar.gz: ddf3e2d95ea98eafc808f0d2e144196257f4880fb1b8d65ae063dd27becb9213
5
5
  SHA512:
6
- metadata.gz: bad0ef5562fe52c213e58b8ae0451e8fe48ae1f1b347f2cb6662e891e9b5893b59210002d5fedf0c3e3b03fcb72ed24ab05c5485c2156e034e1207a5c0f54316
7
- data.tar.gz: 2a92ccf0e821c055f0cbf661c5d5446ba98fd7bd45fb25b7fda6a774027f29aba07411fc962afb0b3f150c6685a768790c2981ba8548f9549b3828c969ed88ec
6
+ metadata.gz: f3463e2d5e75fc653437accecb76a3a66683cd28bf7781a9aadbef167b2cf3f18ad4d5e32fb3cb5f57c6e406929253f6f762ebecec5a4d20b2723c9a8118efd8
7
+ data.tar.gz: f1ccdeca8439a8063940c981f9c05a24abb319bcb0aa76c7212e0883fc6a9c4db7ee10f0e2da1f93105d1bca68844bd108ca7e820b8a462c900d309d35231905
data/CHANGELOG.md CHANGED
@@ -1,6 +1,41 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.6.5
4
+
5
+ ### Added
6
+
7
+ - **Per-route throttling in `routes.rb`.** Any route or resource accepts
8
+ `throttle: { rate: 2, burst: 5 }` (requests/second + burst). `belt routes -f json` emits it
9
+ as `throttle` on the route and conveyor-belt 0.0.20+ applies it as API Gateway stage method
10
+ settings, winning over the Lambda's `throttle:` in `config/lambda/<fn>.yml`. Malformed values
11
+ raise at load time. (Fizzy #1756)
12
+
13
+ ## 0.6.4
14
+
15
+ ### Added
16
+
17
+ - **Plugins can register top-level commands.** A gem that ships
18
+ `lib/belt/commands/<name>_command.rb` defining `Belt::Commands::<Name>Command.run(args)`
19
+ gets `belt <name> ...` (underscores in the file name become hyphens), listed under
20
+ "Plugin Commands" in `belt --help`. Discovery mirrors `GeneratorRegistry`
21
+ (`Belt::CLI::CommandRegistry`). Optional `.description` for help text and `.standalone?`
22
+ to run outside a Belt project. Built-in commands and terraform actions win on a name
23
+ clash. `belt help` / `belt --help` / `belt -h` now print usage and exit 0.
24
+
25
+ ### Fixed
26
+
27
+ - **The data-migrations step of `belt deploy` no longer fails with "You have already
28
+ activated json X, but your Gemfile requires json Y".** The child `belt db:migrate` now
29
+ starts with the app's bundle loaded (`BUNDLE_GEMFILE` + `-rbundler/setup`), so the belt
30
+ CLI's own `require 'json'` can't activate a newer json than the app's lockfile pins.
31
+ - **A fresh environment's Lambdas missing their `config/lambda/*.yml` `env_vars`** (nested
32
+ envs especially, since they are always created fresh) is fixed in conveyor-belt 0.0.19:
33
+ the provider's create path read only the HCL `lambda_config` map and ignored
34
+ `lambda_config_dir`, so the YAML env vars, `iam_policy_arns` and triggers landed only on
35
+ a second deploy. Existing apps on `version = "~> 0.0.16"` pick it up with
36
+ `terraform init -upgrade` in the env's infrastructure directory.
37
+
38
+ ## 0.6.3
4
39
 
5
40
  ### Added
6
41
 
data/README.md CHANGED
@@ -209,6 +209,15 @@ end
209
209
 
210
210
  **Key point:** `namespace` and `scope` are purely organizational — they affect URL paths and controller module resolution but never change which Lambda handles the request. Use `function` when you need routes to go to a different Lambda.
211
211
 
212
+ **Per-route throttling:** any route or resource accepts `throttle: { rate:, burst: }` (steady-state requests per second plus the burst the token bucket absorbs). `belt routes -f json` emits it and the conveyor-belt provider (0.0.20+) applies it as API Gateway stage method settings. A route's `throttle` wins over the serving Lambda's `throttle:` in `config/lambda/<fn>.yml`, which in turn wins over the provider's stage-level `throttle` block.
213
+
214
+ ```ruby
215
+ gateway :api, auth: :none do
216
+ post '/connect/chat', to: 'chat#create', throttle: { rate: 2, burst: 5 }
217
+ resources :leads, only: [:create], throttle: { rate: 1, burst: 3 }
218
+ end
219
+ ```
220
+
212
221
  ### Nested Resource DSL
213
222
 
214
223
  Inside a `resources` block, you can use Rails-like `member`, `collection`, `scope`, and singular `resource` for DRYer route definitions:
@@ -830,6 +839,8 @@ No central registry file, no initializer hook. Belt discovers generators by scan
830
839
 
831
840
  That is the whole contract. After `bundle install`, `belt generate <name>` and `belt destroy <name>` just work.
832
841
 
842
+ Plugins can also add a top-level command: a file at `lib/belt/commands/<name>_command.rb` defining `Belt::Commands::<Name>Command.run(args)` becomes `belt <name> ...` and shows up under "Plugin Commands" in `belt --help`. Optional `.description` and `.standalone?` (run outside a project). Built-in commands win on a name clash. See `Belt::CLI::CommandRegistry` and `belt explain plugins`.
843
+
833
844
  See `Belt::CLI::GeneratorRegistry` and the CHANGELOG entry for **0.1.13 Generator Extension API**.
834
845
 
835
846
  ### Plugin layout (canonical)
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Belt
4
+ module CLI
5
+ # Discovers top-level CLI commands provided by external gems.
6
+ #
7
+ # Gems register commands by placing a file at:
8
+ # lib/belt/commands/<name>_command.rb
9
+ #
10
+ # Each command class must:
11
+ # - Be named <Name>Command (e.g., ConnectCommand)
12
+ # - Live in the Belt::Commands module
13
+ # - Implement .run(args)
14
+ # - Implement .description for help text (optional)
15
+ # - Implement .standalone? returning true to run outside a Belt project (optional)
16
+ #
17
+ # Example gem structure:
18
+ # belt-connect/
19
+ # lib/
20
+ # belt/
21
+ # commands/
22
+ # connect_command.rb
23
+ #
24
+ # The command is then available as:
25
+ # belt connect ...
26
+ #
27
+ # Underscores in the file name become hyphens in the command name
28
+ # (lib/belt/commands/feature_flags_command.rb → `belt feature-flags`).
29
+ # Built-in commands win on a name clash; see Belt::CLI.plugin_command.
30
+ module CommandRegistry
31
+ COMMAND_PATH = 'lib/belt/commands'
32
+
33
+ class << self
34
+ # Returns a hash of { "name" => command_class } for all discovered commands.
35
+ def discovered_commands
36
+ @discovered_commands ||= discover_all
37
+ end
38
+
39
+ # Returns just the names of discovered commands.
40
+ def command_names
41
+ discovered_commands.keys
42
+ end
43
+
44
+ # Returns the command class for a given name, or nil if not found.
45
+ def find(name)
46
+ discovered_commands[name]
47
+ end
48
+
49
+ # Reset cache (useful in tests)
50
+ def reset!
51
+ @discovered_commands = nil
52
+ end
53
+
54
+ private
55
+
56
+ def discover_all
57
+ commands = {}
58
+
59
+ Gem.loaded_specs.each_value do |spec|
60
+ commands_dir = File.join(spec.gem_dir, COMMAND_PATH)
61
+ next unless File.directory?(commands_dir)
62
+
63
+ Dir.glob(File.join(commands_dir, '*_command.rb')).each do |file|
64
+ basename = File.basename(file, '.rb').sub(/_command\z/, '')
65
+ klass = load_command_class(file, basename)
66
+ commands[basename.tr('_', '-')] = klass if klass
67
+ end
68
+ end
69
+
70
+ commands
71
+ end
72
+
73
+ def load_command_class(file, basename)
74
+ require file
75
+
76
+ class_name = "#{classify(basename)}Command"
77
+ Belt::Commands.const_get(class_name, false)
78
+ rescue LoadError, NameError => e
79
+ warn "[Belt] Warning: Failed to load command '#{basename}' from #{file}: #{e.message}"
80
+ nil
81
+ end
82
+
83
+ def classify(name)
84
+ name.split('_').map(&:capitalize).join
85
+ end
86
+ end
87
+ end
88
+ end
89
+
90
+ # Namespace for top-level command classes provided by external gems
91
+ module Commands
92
+ end
93
+ end
@@ -923,13 +923,27 @@ module Belt
923
923
  return true if @skip_migrations || !DbMigrateCommand.migrations?(@project_root)
924
924
 
925
925
  puts "\n━━━ data migrations ━━━"
926
- return true if system(Gem.ruby, BELT_EXE, 'db:migrate', @env, '--yes', chdir: @project_root)
926
+ return true if system(migration_child_env, Gem.ruby, BELT_EXE, 'db:migrate', @env, '--yes',
927
+ chdir: @project_root)
927
928
 
928
929
  puts "\n ⚠ Data migrations failed for #{@env}. The deploy itself succeeded."
929
930
  puts " Fix the migration and run `belt db:migrate #{@env}` to retry."
930
931
  false
931
932
  end
932
933
 
934
+ # Load the app's bundle before the belt CLI does. Otherwise the CLI's own
935
+ # `require 'json'` activates the newest installed json, and db:migrate's
936
+ # `bundler/setup` then fails with "You have already activated json X,
937
+ # but your Gemfile requires json Y" (Fizzy #1734).
938
+ def migration_child_env
939
+ gemfile = File.join(@project_root, 'Gemfile')
940
+ return {} unless File.exist?(gemfile)
941
+
942
+ rubyopt = ENV.fetch('RUBYOPT', '')
943
+ rubyopt = "-rbundler/setup #{rubyopt}".strip unless rubyopt.include?('bundler/setup')
944
+ { 'BUNDLE_GEMFILE' => gemfile, 'RUBYOPT' => rubyopt }
945
+ end
946
+
933
947
  def deploy_frontend_if_exists
934
948
  require_relative 'frontend_registry'
935
949
  require_relative 'frontend_deploy_command'
@@ -165,6 +165,7 @@ module Belt
165
165
  }
166
166
  rc = route.response_context.to_s
167
167
  hash[:response_context] = rc unless rc.empty?
168
+ hash[:throttle] = route.throttle if route.throttle
168
169
  hash
169
170
  end
170
171
 
data/lib/belt/cli.rb CHANGED
@@ -31,6 +31,7 @@ require_relative 'cli/logs_command'
31
31
  require_relative 'cli/doctor_command'
32
32
  require_relative 'cli/plugin_command'
33
33
  require_relative 'cli/explain_command'
34
+ require_relative 'cli/command_registry'
34
35
 
35
36
  module Belt
36
37
  module CLI
@@ -56,7 +57,8 @@ module Belt
56
57
  'dns' => Belt::CLI::DnsCommand,
57
58
  'frontend' => Belt::CLI::FrontendEnvCommand,
58
59
  %w[server s] => Belt::CLI::ServerCommand,
59
- %w[version --version -v] => ->(_args) { puts "Belt #{Belt::VERSION}" }
60
+ %w[version --version -v] => ->(_args) { puts "Belt #{Belt::VERSION}" },
61
+ %w[help --help -h] => ->(_args) { puts usage }
60
62
  }.freeze
61
63
 
62
64
  COMMANDS = COMMANDS_DEFINITION.each_with_object({}) do |(keys, handler), hash|
@@ -66,7 +68,7 @@ module Belt
66
68
  TERRAFORM_ACTIONS = Belt::CLI::TerraformCommand::ACTIONS
67
69
 
68
70
  # Commands that can run without being inside a Belt project
69
- STANDALONE_COMMANDS = %w[new version --version -v doctor explain].freeze
71
+ STANDALONE_COMMANDS = %w[new version --version -v help --help -h doctor explain].freeze
70
72
 
71
73
  def self.start(args)
72
74
  command = args.shift
@@ -76,8 +78,10 @@ module Belt
76
78
  exit 1
77
79
  end
78
80
 
81
+ plugin = plugin_command(command)
82
+
79
83
  # For project-level commands, find and chdir to the project root
80
- ensure_project_root!(command) unless STANDALONE_COMMANDS.include?(command)
84
+ ensure_project_root!(command) unless standalone?(command, plugin)
81
85
 
82
86
  # `belt destroy` is ambiguous: could be terraform destroy or belt destroy <generator>.
83
87
  return if command == 'destroy' && route_destroy_command(args)
@@ -85,7 +89,7 @@ module Belt
85
89
  # Terraform shorthand: belt init wups, belt plan wups, belt apply wups, belt destroy wups
86
90
  return Belt::CLI::TerraformCommand.run(command, args) if TERRAFORM_ACTIONS.include?(command)
87
91
 
88
- handler = COMMANDS[command]
92
+ handler = COMMANDS[command] || plugin
89
93
 
90
94
  # If no built-in command matched, try running it as a rake task
91
95
  if handler.nil?
@@ -102,7 +106,43 @@ module Belt
102
106
  end
103
107
  end
104
108
 
109
+ # Returns the gem-provided command class for `name`, or nil. Built-in
110
+ # commands and terraform actions win on a name clash.
111
+ def self.plugin_command(name)
112
+ return nil if name.nil? || builtin_command?(name)
113
+
114
+ CommandRegistry.find(name)
115
+ end
116
+
117
+ def self.builtin_command?(name)
118
+ COMMANDS.key?(name) || TERRAFORM_ACTIONS.include?(name)
119
+ end
120
+
121
+ def self.standalone?(command, plugin)
122
+ return STANDALONE_COMMANDS.include?(command) unless plugin
123
+
124
+ plugin.respond_to?(:standalone?) && plugin.standalone?
125
+ end
126
+
105
127
  def self.usage
128
+ base_usage.sub("\nRake Tasks:\n", "#{plugin_usage}\nRake Tasks:\n")
129
+ end
130
+
131
+ # "Plugin Commands:" help section for gem-provided commands ("" when none).
132
+ def self.plugin_usage
133
+ names = CommandRegistry.command_names.reject { |name| builtin_command?(name) }.sort
134
+ return '' if names.empty?
135
+
136
+ lines = names.map do |name|
137
+ klass = CommandRegistry.find(name)
138
+ description = klass.respond_to?(:description) ? klass.description.to_s : ''
139
+ " #{name.ljust(42)} #{description}".rstrip
140
+ end
141
+
142
+ "\nPlugin Commands:\n#{lines.join("\n")}\n"
143
+ end
144
+
145
+ def self.base_usage
106
146
  <<~USAGE
107
147
  Usage: belt <command> [options]
108
148
 
@@ -151,6 +191,7 @@ module Belt
151
191
  destroy [environment] <env> terraform destroy for environment
152
192
  output [environment] <env> terraform output for environment
153
193
  --version Show Belt version
194
+ help, --help, -h Show this help
154
195
 
155
196
  Rake Tasks:
156
197
  Any rake task from your Gemfile dependencies can be run directly:
@@ -45,6 +45,38 @@ Belt discovers plugins automatically when:
45
45
 
46
46
  Optional: `.destroy(args)` and `.description` for destroy path and help text.
47
47
 
48
+ ## Top-level Commands
49
+
50
+ A plugin can also add its own `belt <name> ...` command, the way `belt-connect`
51
+ adds `belt connect`:
52
+
53
+ 1. Gem has a file at `lib/belt/commands/<name>_command.rb`
54
+ 2. Class is `Belt::Commands::<Name>Command`
55
+ 3. Class implements `.run(args)` (required); `args` is everything after `<name>`
56
+
57
+ Optional: `.description` (shown under "Plugin Commands" in `belt --help`) and
58
+ `.standalone?` returning `true` to run outside a Belt project. Otherwise the
59
+ command runs from the project root, like built-ins.
60
+
61
+ Underscores in the file name become hyphens (`feature_flags_command.rb` →
62
+ `belt feature-flags`). Built-in commands and terraform actions win on a name
63
+ clash, so a plugin can't shadow `belt deploy`.
64
+
65
+ ```ruby
66
+ # lib/belt/commands/connect_command.rb
67
+ module Belt
68
+ module Commands
69
+ class ConnectCommand
70
+ def self.description = 'Connect widgets to your app'
71
+
72
+ def self.run(args)
73
+ # belt connect widget orders --env dev → args == ['widget', 'orders', '--env', 'dev']
74
+ end
75
+ end
76
+ end
77
+ end
78
+ ```
79
+
48
80
  ## Generator Checklist
49
81
 
50
82
  A good plugin generator typically installs:
@@ -71,6 +103,8 @@ belt-messaging/
71
103
  │ │ ├── version.rb
72
104
  │ │ ├── controllers/ # Default controllers
73
105
  │ │ └── templates/ # ERB templates for generator
106
+ │ ├── commands/
107
+ │ │ └── messaging_command.rb # ← optional `belt messaging`
74
108
  │ └── generators/
75
109
  │ └── messaging_generator.rb # ← auto-discovered
76
110
  └── spec/
@@ -9,7 +9,7 @@ module Belt
9
9
 
10
10
  class Route
11
11
  attr_reader :method, :path, :auth, :lambda, :cors, :tables, :route_type,
12
- :controller, :action, :request_model, :response_model, :response_context
12
+ :controller, :action, :request_model, :response_model, :response_context, :throttle
13
13
 
14
14
  def initialize(method, path, options = {})
15
15
  @method = method.to_s.upcase
@@ -17,6 +17,7 @@ module Belt
17
17
  @auth = options[:auth]
18
18
  @lambda = options[:lambda]
19
19
  @cors = options.fetch(:cors, true)
20
+ @throttle = self.class.normalize_throttle(options[:throttle])
20
21
  @tables = options[:tables] || []
21
22
  @route_type = options[:route_type] || :action
22
23
  @controller = options[:controller]
@@ -26,6 +27,31 @@ module Belt
26
27
  @response_context = options[:response_context]&.to_s
27
28
  end
28
29
 
30
+ # Per-route API Gateway limit: `throttle: { rate: 2, burst: 5 }` (requests per
31
+ # second, token bucket). Emitted by `belt routes -f json` and applied by the
32
+ # conveyor-belt provider (0.0.20+) as stage method settings; it wins over the
33
+ # Lambda's `throttle:` in config/lambda/<fn>.yml. Returns nil when unset.
34
+ def self.normalize_throttle(raw)
35
+ return nil if raw.nil?
36
+
37
+ unless raw.is_a?(Hash)
38
+ raise ArgumentError, "throttle must be a Hash like { rate: 2, burst: 5 } (got #{raw.inspect})"
39
+ end
40
+
41
+ rate = raw[:rate] || raw['rate']
42
+ burst = raw[:burst] || raw['burst']
43
+ raise ArgumentError, 'throttle needs rate (requests/second) and burst' if rate.nil? || burst.nil?
44
+
45
+ unless rate.is_a?(Numeric) && rate.positive?
46
+ raise ArgumentError, "throttle rate must be a number > 0 (got #{rate.inspect})"
47
+ end
48
+ unless burst.is_a?(Integer) && burst.positive?
49
+ raise ArgumentError, "throttle burst must be an integer > 0 (got #{burst.inspect})"
50
+ end
51
+
52
+ { rate: rate, burst: burst }
53
+ end
54
+
29
55
  def resource?
30
56
  @route_type == :resource || @route_type == :resources
31
57
  end
@@ -494,7 +520,8 @@ module Belt
494
520
  action: action,
495
521
  request_model: options[:request_model],
496
522
  response_model: options[:response_model],
497
- response_context: options[:response_context]
523
+ response_context: options[:response_context],
524
+ throttle: options[:throttle]
498
525
  }
499
526
 
500
527
  @routes << Belt::Route.new(method, path, route_options)
data/lib/belt/version.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Belt
4
- VERSION = '0.6.3'
4
+ VERSION = '0.6.5'
5
5
 
6
6
  # Ruby version new apps run on in Lambda. Rendered into the conveyor-belt
7
7
  # provider block (`ruby_version`) by `belt new` / `belt generate environment`.
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.6.3
4
+ version: 0.6.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stowzilla
@@ -118,6 +118,7 @@ files:
118
118
  - lib/belt/cli/backup_runner.rb
119
119
  - lib/belt/cli/bucket_security.rb
120
120
  - lib/belt/cli/cognito_sharer.rb
121
+ - lib/belt/cli/command_registry.rb
121
122
  - lib/belt/cli/console_command.rb
122
123
  - lib/belt/cli/contracts_command.rb
123
124
  - lib/belt/cli/db_copy_command.rb