belt 0.2.10 → 0.2.11
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 +4 -4
- data/CHANGELOG.md +32 -0
- data/README.md +169 -0
- data/lib/belt/action_router.rb +32 -4
- data/lib/belt/cli/destroy_command.rb +119 -10
- data/lib/belt/cli/environment_command.rb +32 -0
- data/lib/belt/cli/plugin_command.rb +221 -0
- data/lib/belt/cli.rb +4 -0
- data/lib/belt/helpers/cors_origin.rb +19 -2
- data/lib/belt/helpers/response.rb +13 -2
- data/lib/belt/lambda_handler.rb +14 -5
- data/lib/belt/version.rb +1 -1
- data/lib/templates/environment/backend.tf.erb +1 -2
- data/lib/templates/plugin/AGENTS.md.erb +135 -0
- data/lib/templates/plugin/CHANGELOG.md.erb +5 -0
- data/lib/templates/plugin/Gemfile.erb +11 -0
- data/lib/templates/plugin/LICENSE.erb +21 -0
- data/lib/templates/plugin/README.md.erb +63 -0
- data/lib/templates/plugin/Rakefile.erb +7 -0
- data/lib/templates/plugin/gemspec.erb +26 -0
- data/lib/templates/plugin/gitignore.erb +11 -0
- data/lib/templates/plugin/lib/belt/generators/generator.rb.erb +122 -0
- data/lib/templates/plugin/lib/belt/module/configuration.rb.erb +15 -0
- data/lib/templates/plugin/lib/belt/module/version.rb.erb +7 -0
- data/lib/templates/plugin/lib/belt/module.rb.erb +27 -0
- data/lib/templates/plugin/lib/entry.rb.erb +3 -0
- data/lib/templates/plugin/rspec.erb +3 -0
- data/lib/templates/plugin/spec/configuration_spec.rb.erb +17 -0
- data/lib/templates/plugin/spec/spec_helper.rb.erb +19 -0
- metadata +18 -1
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'erb'
|
|
5
|
+
|
|
6
|
+
module Belt
|
|
7
|
+
module CLI
|
|
8
|
+
# Scaffold a new Belt plugin gem (similar to `rails plugin new`).
|
|
9
|
+
#
|
|
10
|
+
# Usage:
|
|
11
|
+
# belt plugin new messaging
|
|
12
|
+
# belt plugin new belt-pay --path ~/code
|
|
13
|
+
#
|
|
14
|
+
# Creates a gem that registers with Belt's GeneratorRegistry via:
|
|
15
|
+
# lib/belt/generators/<name>_generator.rb
|
|
16
|
+
class PluginCommand
|
|
17
|
+
TEMPLATE_DIR = File.expand_path('../../templates/plugin', __dir__)
|
|
18
|
+
|
|
19
|
+
def self.run(args)
|
|
20
|
+
subcommand = args.shift
|
|
21
|
+
|
|
22
|
+
case subcommand
|
|
23
|
+
when 'new'
|
|
24
|
+
new(args).generate
|
|
25
|
+
when nil, '--help', '-h', 'help'
|
|
26
|
+
print_help
|
|
27
|
+
else
|
|
28
|
+
puts "Unknown plugin subcommand: #{subcommand}"
|
|
29
|
+
puts ''
|
|
30
|
+
print_help
|
|
31
|
+
exit 1
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.print_help
|
|
36
|
+
puts <<~HELP
|
|
37
|
+
Manage Belt plugin gems.
|
|
38
|
+
|
|
39
|
+
Usage:
|
|
40
|
+
belt plugin new <name> [options]
|
|
41
|
+
|
|
42
|
+
Arguments:
|
|
43
|
+
name Plugin name (e.g. messaging, pay, or belt-messaging)
|
|
44
|
+
|
|
45
|
+
Options:
|
|
46
|
+
--path DIR Parent directory for the gem (default: current directory)
|
|
47
|
+
--summary TEXT Short gem summary for the gemspec
|
|
48
|
+
--force Overwrite files if the directory already exists
|
|
49
|
+
-h, --help Show this help
|
|
50
|
+
|
|
51
|
+
Examples:
|
|
52
|
+
belt plugin new messaging
|
|
53
|
+
belt plugin new pay --path ~/Code
|
|
54
|
+
belt plugin new belt-notifications --summary "Push notifications for Belt apps"
|
|
55
|
+
|
|
56
|
+
What you get:
|
|
57
|
+
A standalone gem (belt-<name>) with:
|
|
58
|
+
- Module under Belt::<Name>
|
|
59
|
+
- Generator at lib/belt/generators/<name>_generator.rb
|
|
60
|
+
- Configuration stub, version, RSpec, README, AGENTS.md
|
|
61
|
+
- Hooked into `belt generate <name>` / `belt destroy <name>` once
|
|
62
|
+
the gem is in an app's Gemfile
|
|
63
|
+
|
|
64
|
+
Reference plugins:
|
|
65
|
+
belt-messaging — SMS via AWS End User Messaging
|
|
66
|
+
belt-pay — Stripe payments and subscriptions
|
|
67
|
+
|
|
68
|
+
See the Belt README (Plugins / Contributing) and AGENTS.md for the full guide.
|
|
69
|
+
HELP
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def initialize(args)
|
|
73
|
+
@raw_name = nil
|
|
74
|
+
@path = Dir.pwd
|
|
75
|
+
@summary = nil
|
|
76
|
+
@force = false
|
|
77
|
+
|
|
78
|
+
i = 0
|
|
79
|
+
while i < args.length
|
|
80
|
+
arg = args[i]
|
|
81
|
+
case arg
|
|
82
|
+
when '--path'
|
|
83
|
+
i += 1
|
|
84
|
+
@path = args[i] || Dir.pwd
|
|
85
|
+
when /^--path=/
|
|
86
|
+
@path = arg.split('=', 2).last
|
|
87
|
+
when '--summary'
|
|
88
|
+
i += 1
|
|
89
|
+
@summary = args[i]
|
|
90
|
+
when /^--summary=/
|
|
91
|
+
@summary = arg.split('=', 2).last
|
|
92
|
+
when '--force'
|
|
93
|
+
@force = true
|
|
94
|
+
when '--help', '-h'
|
|
95
|
+
self.class.print_help
|
|
96
|
+
exit 0
|
|
97
|
+
else
|
|
98
|
+
if arg.start_with?('-')
|
|
99
|
+
puts "Unknown option: #{arg}"
|
|
100
|
+
exit 1
|
|
101
|
+
end
|
|
102
|
+
@raw_name ||= arg
|
|
103
|
+
end
|
|
104
|
+
i += 1
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def generate
|
|
109
|
+
if @raw_name.nil? || @raw_name.strip.empty?
|
|
110
|
+
puts 'Usage: belt plugin new <name> [options]'
|
|
111
|
+
puts "Run 'belt plugin --help' for more information."
|
|
112
|
+
exit 1
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
normalize_names!
|
|
116
|
+
|
|
117
|
+
if Dir.exist?(@gem_dir) && !@force
|
|
118
|
+
puts "✗ Directory already exists: #{@gem_dir}"
|
|
119
|
+
puts ' Use --force to overwrite, or choose a different name/path.'
|
|
120
|
+
exit 1
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
FileUtils.mkdir_p(@gem_dir)
|
|
124
|
+
write_files
|
|
125
|
+
print_success
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
def normalize_names!
|
|
131
|
+
# Accept "messaging", "belt-messaging", "Belt::Messaging"
|
|
132
|
+
name = @raw_name.to_s.strip
|
|
133
|
+
name = name.sub(/\ABelt::/i, '')
|
|
134
|
+
name = name.gsub('::', '-')
|
|
135
|
+
name = name.sub(/\Abelt[-_]/i, '')
|
|
136
|
+
name = name.gsub(/[^a-z0-9_-]/i, '_').downcase.tr('_', '-')
|
|
137
|
+
name = name.gsub(/-+/, '-').gsub(/\A-|-\z/, '')
|
|
138
|
+
|
|
139
|
+
if name.empty?
|
|
140
|
+
puts "✗ Invalid plugin name: #{@raw_name.inspect}"
|
|
141
|
+
exit 1
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
@plugin_name = name # messaging
|
|
145
|
+
@gem_name = "belt-#{name}" # belt-messaging
|
|
146
|
+
@module_name = name.split('-').map(&:capitalize).join # Messaging
|
|
147
|
+
@constant_path = "Belt::#{@module_name}" # Belt::Messaging
|
|
148
|
+
@generator_class = "#{@module_name}Generator"
|
|
149
|
+
@summary ||= "#{@module_name} plugin for Belt applications"
|
|
150
|
+
@gem_dir = File.expand_path(File.join(@path, @gem_name))
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def write_files
|
|
154
|
+
files.each do |relative, content|
|
|
155
|
+
full = File.join(@gem_dir, relative)
|
|
156
|
+
FileUtils.mkdir_p(File.dirname(full))
|
|
157
|
+
File.write(full, content)
|
|
158
|
+
puts " create #{File.join(@gem_name, relative)}"
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def files
|
|
163
|
+
gen = "lib/belt/generators/#{@plugin_name.tr('-', '_')}_generator.rb"
|
|
164
|
+
{
|
|
165
|
+
"#{@gem_name}.gemspec" => render('gemspec.erb'),
|
|
166
|
+
'Gemfile' => render('Gemfile.erb'),
|
|
167
|
+
'Rakefile' => render('Rakefile.erb'),
|
|
168
|
+
'README.md' => render('README.md.erb'),
|
|
169
|
+
'AGENTS.md' => render('AGENTS.md.erb'),
|
|
170
|
+
'CHANGELOG.md' => render('CHANGELOG.md.erb'),
|
|
171
|
+
'LICENSE' => render('LICENSE.erb'),
|
|
172
|
+
'.gitignore' => render('gitignore.erb'),
|
|
173
|
+
'.rspec' => render('rspec.erb'),
|
|
174
|
+
"lib/#{@gem_name}.rb" => render('lib/entry.rb.erb'),
|
|
175
|
+
"lib/belt/#{@plugin_name.tr('-', '_')}.rb" => render('lib/belt/module.rb.erb'),
|
|
176
|
+
"lib/belt/#{@plugin_name.tr('-', '_')}/version.rb" => render('lib/belt/module/version.rb.erb'),
|
|
177
|
+
"lib/belt/#{@plugin_name.tr('-', '_')}/configuration.rb" => render('lib/belt/module/configuration.rb.erb'),
|
|
178
|
+
gen => render('lib/belt/generators/generator.rb.erb'),
|
|
179
|
+
"lib/belt/#{@plugin_name.tr('-', '_')}/templates/.gitkeep" => '',
|
|
180
|
+
'spec/spec_helper.rb' => render('spec/spec_helper.rb.erb'),
|
|
181
|
+
"spec/belt/#{@plugin_name.tr('-', '_')}/configuration_spec.rb" => render('spec/configuration_spec.rb.erb')
|
|
182
|
+
}
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def render(template_name)
|
|
186
|
+
path = File.join(TEMPLATE_DIR, template_name)
|
|
187
|
+
raise "Missing plugin template: #{path}" unless File.exist?(path)
|
|
188
|
+
|
|
189
|
+
erb = ERB.new(File.read(path), trim_mode: '-')
|
|
190
|
+
# Expose locals used in templates
|
|
191
|
+
gem_name = @gem_name
|
|
192
|
+
plugin_name = @plugin_name
|
|
193
|
+
module_name = @module_name
|
|
194
|
+
constant_path = @constant_path
|
|
195
|
+
generator_class = @generator_class
|
|
196
|
+
summary = @summary
|
|
197
|
+
underscored = @plugin_name.tr('-', '_')
|
|
198
|
+
erb.result(binding)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def print_success
|
|
202
|
+
puts ''
|
|
203
|
+
puts "✓ Created plugin gem #{@gem_name}"
|
|
204
|
+
puts ''
|
|
205
|
+
puts 'Next steps:'
|
|
206
|
+
puts " 1. cd #{@gem_dir}"
|
|
207
|
+
puts ' 2. bundle install'
|
|
208
|
+
puts ' 3. Implement your library under lib/belt/'
|
|
209
|
+
puts " 4. Flesh out lib/belt/generators/#{@plugin_name.tr('-', '_')}_generator.rb"
|
|
210
|
+
puts ' (templates go in lib/belt/<name>/templates/)'
|
|
211
|
+
puts ' 5. In a Belt app Gemfile:'
|
|
212
|
+
puts " gem \"#{@gem_name}\", path: \"#{@gem_dir}\""
|
|
213
|
+
puts ' 6. bundle install && belt generate --help'
|
|
214
|
+
puts " → should list \"#{@plugin_name}\" under Gem Generators"
|
|
215
|
+
puts " 7. belt generate #{@plugin_name}"
|
|
216
|
+
puts ''
|
|
217
|
+
puts 'Reference implementations: belt-messaging, belt-pay'
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
data/lib/belt/cli.rb
CHANGED
|
@@ -22,6 +22,7 @@ require_relative 'cli/lambda_config_command'
|
|
|
22
22
|
require_relative 'cli/tasks_command'
|
|
23
23
|
require_relative 'cli/console_command'
|
|
24
24
|
require_relative 'cli/doctor_command'
|
|
25
|
+
require_relative 'cli/plugin_command'
|
|
25
26
|
|
|
26
27
|
module Belt
|
|
27
28
|
module CLI
|
|
@@ -35,6 +36,7 @@ module Belt
|
|
|
35
36
|
%w[tasks --tasks -T] => Belt::CLI::TasksCommand,
|
|
36
37
|
'setup' => Belt::CLI::SetupCommand,
|
|
37
38
|
'doctor' => Belt::CLI::DoctorCommand,
|
|
39
|
+
'plugin' => Belt::CLI::PluginCommand,
|
|
38
40
|
'deploy' => Belt::CLI::DeployCommand,
|
|
39
41
|
'frontend' => Belt::CLI::FrontendEnvCommand,
|
|
40
42
|
%w[server s] => Belt::CLI::ServerCommand,
|
|
@@ -117,6 +119,7 @@ module Belt
|
|
|
117
119
|
setup tables <env> Generate DynamoDB tables from schema
|
|
118
120
|
setup frontend <env> Generate S3 + CloudFront infrastructure
|
|
119
121
|
doctor Check system dependencies and AWS config
|
|
122
|
+
plugin new <name> Scaffold a new Belt plugin gem
|
|
120
123
|
init [environment] <env> terraform init for environment
|
|
121
124
|
plan [environment] <env> terraform plan for environment
|
|
122
125
|
apply [environment] <env> terraform apply for environment
|
|
@@ -149,6 +152,7 @@ module Belt
|
|
|
149
152
|
belt apply wups
|
|
150
153
|
belt tasks # list all rake tasks
|
|
151
154
|
belt lambda:build_layer # run a rake task directly
|
|
155
|
+
belt plugin new messaging # scaffold a belt-messaging style plugin gem
|
|
152
156
|
USAGE
|
|
153
157
|
end
|
|
154
158
|
end
|
|
@@ -3,10 +3,16 @@
|
|
|
3
3
|
module Belt
|
|
4
4
|
module Helpers
|
|
5
5
|
module CorsOrigin
|
|
6
|
+
# Maximum length for an Origin header to prevent ReDoS or memory abuse.
|
|
7
|
+
MAX_ORIGIN_LENGTH = 253
|
|
8
|
+
|
|
6
9
|
def self.resolve_origin(request_origin)
|
|
7
10
|
allowed = allowed_origins
|
|
8
11
|
return nil if allowed.empty?
|
|
9
|
-
|
|
12
|
+
|
|
13
|
+
if request_origin && valid_origin?(request_origin) && matches_allowed?(request_origin, allowed)
|
|
14
|
+
return request_origin
|
|
15
|
+
end
|
|
10
16
|
|
|
11
17
|
# Don't return a wildcard pattern as a literal origin — use '*' instead
|
|
12
18
|
first = allowed.first
|
|
@@ -18,7 +24,7 @@ module Belt
|
|
|
18
24
|
|
|
19
25
|
allowed.any? do |pattern|
|
|
20
26
|
if pattern.include?('*')
|
|
21
|
-
regex = Regexp.new("\\A#{Regexp.escape(pattern).gsub('\*', '[
|
|
27
|
+
regex = Regexp.new("\\A#{Regexp.escape(pattern).gsub('\*', '[a-z0-9\\-]+')}\\z")
|
|
22
28
|
regex.match?(origin)
|
|
23
29
|
else
|
|
24
30
|
pattern == origin
|
|
@@ -26,6 +32,17 @@ module Belt
|
|
|
26
32
|
end
|
|
27
33
|
end
|
|
28
34
|
|
|
35
|
+
# Validate that the origin looks like a legitimate URL scheme+host.
|
|
36
|
+
# Rejects origins with path components, whitespace, or unexpected characters.
|
|
37
|
+
def self.valid_origin?(origin)
|
|
38
|
+
return false if origin.nil? || origin.empty?
|
|
39
|
+
return false if origin.length > MAX_ORIGIN_LENGTH
|
|
40
|
+
return false unless origin.match?(%r{\A https?://[a-z0-9\-.:]+\z}ix)
|
|
41
|
+
|
|
42
|
+
# Reject origins with user-info, paths, queries, or fragments
|
|
43
|
+
!origin.include?('@') && !origin.include?('?') && !origin.include?('#')
|
|
44
|
+
end
|
|
45
|
+
|
|
29
46
|
def self.origin_from_event(event)
|
|
30
47
|
return nil unless event.is_a?(Hash)
|
|
31
48
|
|
|
@@ -7,6 +7,10 @@ require_relative '../http_status'
|
|
|
7
7
|
module Belt
|
|
8
8
|
module Helpers
|
|
9
9
|
module Response
|
|
10
|
+
# Maximum request body size (10 MB). Bodies larger than this are rejected
|
|
11
|
+
# to prevent memory exhaustion attacks on Lambda functions.
|
|
12
|
+
MAX_REQUEST_BODY_SIZE = 10 * 1024 * 1024
|
|
13
|
+
|
|
10
14
|
def cors_headers(event = nil)
|
|
11
15
|
event = @event if event.nil? && instance_variable_defined?(:@event)
|
|
12
16
|
origin = CorsOrigin.resolve_origin(CorsOrigin.origin_from_event(event))
|
|
@@ -15,7 +19,9 @@ module Belt
|
|
|
15
19
|
'Access-Control-Allow-Headers' => allow_headers,
|
|
16
20
|
'Access-Control-Allow-Methods' => 'GET,POST,PUT,DELETE,PATCH,OPTIONS',
|
|
17
21
|
'Access-Control-Max-Age' => '300',
|
|
18
|
-
'Content-Type' => 'application/json'
|
|
22
|
+
'Content-Type' => 'application/json',
|
|
23
|
+
'X-Content-Type-Options' => 'nosniff',
|
|
24
|
+
'Vary' => 'Origin'
|
|
19
25
|
}
|
|
20
26
|
headers['Access-Control-Allow-Origin'] = origin if origin
|
|
21
27
|
headers
|
|
@@ -38,7 +44,12 @@ module Belt
|
|
|
38
44
|
def html_response(html, status_code = 200)
|
|
39
45
|
event = @event if instance_variable_defined?(:@event)
|
|
40
46
|
origin = CorsOrigin.resolve_origin(CorsOrigin.origin_from_event(event))
|
|
41
|
-
headers = {
|
|
47
|
+
headers = {
|
|
48
|
+
'Content-Type' => 'text/html; charset=utf-8',
|
|
49
|
+
'X-Content-Type-Options' => 'nosniff',
|
|
50
|
+
'X-Frame-Options' => 'DENY',
|
|
51
|
+
'Referrer-Policy' => 'strict-origin-when-cross-origin'
|
|
52
|
+
}
|
|
42
53
|
headers['Access-Control-Allow-Origin'] = origin if origin
|
|
43
54
|
{ statusCode: resolve_status(status_code), headers: headers, body: html }
|
|
44
55
|
end
|
data/lib/belt/lambda_handler.rb
CHANGED
|
@@ -76,11 +76,8 @@ module Belt
|
|
|
76
76
|
|
|
77
77
|
return { statusCode: 200, headers: cors_headers(event), body: '{}' } if event['httpMethod'] == 'OPTIONS'
|
|
78
78
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
rescue JSON::ParserError
|
|
82
|
-
return error_response('Invalid JSON in request body')
|
|
83
|
-
end
|
|
79
|
+
body = parse_request_body(event)
|
|
80
|
+
return body if body.is_a?(Hash) && body[:statusCode]
|
|
84
81
|
|
|
85
82
|
begin
|
|
86
83
|
result = execute(path: event['path'], body: body, event: event)
|
|
@@ -107,6 +104,18 @@ module Belt
|
|
|
107
104
|
|
|
108
105
|
private
|
|
109
106
|
|
|
107
|
+
def parse_request_body(event)
|
|
108
|
+
raw_body = event['body'] || '{}'
|
|
109
|
+
|
|
110
|
+
if raw_body.bytesize > Belt::Helpers::Response::MAX_REQUEST_BODY_SIZE
|
|
111
|
+
return error_response('Request body too large', 413)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
JSON.parse(raw_body)
|
|
115
|
+
rescue JSON::ParserError
|
|
116
|
+
error_response('Invalid JSON in request body', 400)
|
|
117
|
+
end
|
|
118
|
+
|
|
110
119
|
def init_observability(context:)
|
|
111
120
|
service_name = ENV['ACTION'] || context.function_name.split('-').last
|
|
112
121
|
|
data/lib/belt/version.rb
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
terraform {
|
|
2
2
|
backend "s3" {
|
|
3
|
-
|
|
4
|
-
bucket = "belt-terraform-state"
|
|
3
|
+
bucket = "<%= @state_bucket %>"
|
|
5
4
|
key = "<%= s3_safe_name(@app_name) %>/<%= @env_name %>/terraform.tfstate"
|
|
6
5
|
region = "us-east-1"
|
|
7
6
|
encrypt = true
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# AGENTS.md — <%= gem_name %>
|
|
2
|
+
|
|
3
|
+
This file is for AI agents (and humans) working **on this Belt plugin gem**, not on a host Belt app.
|
|
4
|
+
|
|
5
|
+
Host apps get their own `AGENTS.md` from `belt new`. Core belt docs live in the [belt](https://github.com/stowzilla/belt) repo (`README.md` + root `AGENTS.md`).
|
|
6
|
+
|
|
7
|
+
## What this gem is
|
|
8
|
+
|
|
9
|
+
**<%= gem_name %>** is a Belt plugin: optional capability that installs into Belt apps via the generator API.
|
|
10
|
+
|
|
11
|
+
- **Runtime API** lives in the gem (`<%= constant_path %>`) — apps require the gem and call it directly
|
|
12
|
+
- **Generator** copies only what the host app must own (Terraform, Lambda entrypoints, optional overrides)
|
|
13
|
+
- After the gem is in an app's Gemfile, `belt generate <%= plugin_name %>` / `belt destroy <%= plugin_name %>` work automatically
|
|
14
|
+
|
|
15
|
+
## Stack
|
|
16
|
+
|
|
17
|
+
| Piece | Role |
|
|
18
|
+
|-------|------|
|
|
19
|
+
| **belt** | Host framework + CLI; discovers this generator via `GeneratorRegistry` |
|
|
20
|
+
| **<%= gem_name %>** (this repo) | Plugin runtime + `belt generate <%= plugin_name %>` |
|
|
21
|
+
| Host Belt app | Where Terraform / Lambda config land after generation |
|
|
22
|
+
|
|
23
|
+
## Layout
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
<%= gem_name %>/
|
|
27
|
+
├── <%= gem_name %>.gemspec
|
|
28
|
+
├── lib/
|
|
29
|
+
│ ├── <%= gem_name %>.rb # require entrypoint
|
|
30
|
+
│ └── belt/
|
|
31
|
+
│ ├── <%= underscored %>.rb # <%= constant_path %> API
|
|
32
|
+
│ ├── <%= underscored %>/
|
|
33
|
+
│ │ ├── configuration.rb
|
|
34
|
+
│ │ ├── version.rb
|
|
35
|
+
│ │ └── templates/ # ERB used by the generator
|
|
36
|
+
│ └── generators/
|
|
37
|
+
│ └── <%= underscored %>_generator.rb # ← auto-discovered
|
|
38
|
+
├── spec/
|
|
39
|
+
├── README.md
|
|
40
|
+
└── AGENTS.md # This file
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
**Do not rename** `lib/belt/generators/<%= underscored %>_generator.rb` or the class
|
|
44
|
+
`Belt::Generators::<%= generator_class %>` — discovery depends on that path and name.
|
|
45
|
+
|
|
46
|
+
## Generator contract
|
|
47
|
+
|
|
48
|
+
Implemented in `lib/belt/generators/<%= underscored %>_generator.rb`:
|
|
49
|
+
|
|
50
|
+
| Method | Required? | Purpose |
|
|
51
|
+
|--------|-----------|---------|
|
|
52
|
+
| `.run(args)` | **Yes** | Install into the current Belt app |
|
|
53
|
+
| `.destroy(args)` | Recommended | Tear down what generate created |
|
|
54
|
+
| `.description` | Recommended | One-liner for `belt generate --help` |
|
|
55
|
+
|
|
56
|
+
Class must live in `Belt::Generators` and be named `<%= generator_class %>`.
|
|
57
|
+
|
|
58
|
+
Reference implementations (copy structure, don't invent a parallel one):
|
|
59
|
+
|
|
60
|
+
- `belt-messaging` — SMS via AWS End User Messaging
|
|
61
|
+
- `belt-pay` — Stripe payments & subscriptions
|
|
62
|
+
|
|
63
|
+
## Generator checklist
|
|
64
|
+
|
|
65
|
+
When fleshing out the generator, typically install:
|
|
66
|
+
|
|
67
|
+
1. **Terraform module** → `infrastructure/modules/<%= plugin_name %>/` (`main.tf`, `variables.tf`, `outputs.tf`)
|
|
68
|
+
2. **Lambda config** → `config/lambda/<%= plugin_name %>.yml` (timeout, memory, env, triggers)
|
|
69
|
+
3. **Lambda entrypoint** → `lambda/<%= plugin_name %>.rb` using `Belt::LambdaHandler`
|
|
70
|
+
4. **Routes / schema** → inject into `config/routes.tf.rb` or schema files when needed
|
|
71
|
+
5. **Optional overrides** → `--controllers` (or similar) for app-local subclasses — keep defaults in the gem
|
|
72
|
+
6. **Destroy path** → `belt destroy <%= plugin_name %>` removes generated artifacts
|
|
73
|
+
7. **Help** → `.description` plus `--help` / `-h` explaining files and next steps
|
|
74
|
+
|
|
75
|
+
**Principle:** runtime stays in the gem; generators copy only host-owned infra and optional overrides (same idea as `rails g devise:views`).
|
|
76
|
+
|
|
77
|
+
## Local development
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
bundle install
|
|
81
|
+
bundle exec rspec
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Wire into a Belt app while iterating:
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
# In the app Gemfile
|
|
88
|
+
gem "<%= gem_name %>", path: "../<%= gem_name %>"
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
cd /path/to/belt-app
|
|
93
|
+
bundle install
|
|
94
|
+
belt generate --help # should list "<%= plugin_name %>" under Gem Generators
|
|
95
|
+
belt generate <%= plugin_name %>
|
|
96
|
+
belt destroy <%= plugin_name %>
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`belt deploy` vendors `path:` gems into `vendor/cache` so Lambda packaging works without publishing.
|
|
100
|
+
|
|
101
|
+
## Configuration pattern
|
|
102
|
+
|
|
103
|
+
```ruby
|
|
104
|
+
<%= constant_path %>.configure do |config|
|
|
105
|
+
# config.option = value
|
|
106
|
+
end
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Extend `lib/belt/<%= underscored %>/configuration.rb` and document options in README.
|
|
110
|
+
|
|
111
|
+
## Conventions
|
|
112
|
+
|
|
113
|
+
1. Follow patterns from `belt-messaging` / `belt-pay` before inventing new structure
|
|
114
|
+
2. Keep the generator idempotent where practical; support `--force` for overwrites
|
|
115
|
+
3. Specs under `spec/` for configuration and any non-trivial generator logic
|
|
116
|
+
4. User-facing changes → `CHANGELOG.md`
|
|
117
|
+
5. Never commit secrets, API keys, or real AWS account IDs
|
|
118
|
+
6. Prefer gem defaults + optional generate flags over dumping all code into the app
|
|
119
|
+
|
|
120
|
+
## Where to look first
|
|
121
|
+
|
|
122
|
+
| Task | Start here |
|
|
123
|
+
|------|------------|
|
|
124
|
+
| Runtime API | `lib/belt/<%= underscored %>.rb`, `lib/belt/<%= underscored %>/` |
|
|
125
|
+
| What gets installed into apps | `lib/belt/generators/<%= underscored %>_generator.rb` |
|
|
126
|
+
| Generator ERB templates | `lib/belt/<%= underscored %>/templates/` |
|
|
127
|
+
| Version / gemspec | `lib/belt/<%= underscored %>/version.rb`, `<%= gem_name %>.gemspec` |
|
|
128
|
+
| How belt discovers this | belt gem: `Belt::CLI::GeneratorRegistry` |
|
|
129
|
+
|
|
130
|
+
## Do not
|
|
131
|
+
|
|
132
|
+
- Move the generator file out of `lib/belt/generators/*_generator.rb`
|
|
133
|
+
- Require a manual initializer in the host app for registration — discovery is automatic
|
|
134
|
+
- Put host-app deploy runbooks here — those belong in the app's `AGENTS.md`
|
|
135
|
+
- Duplicate large runtime classes into generated app files unless the user opts in (e.g. `--controllers`)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) <%= Time.now.year %>
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# <%= gem_name %>
|
|
2
|
+
|
|
3
|
+
<%= summary %>.
|
|
4
|
+
|
|
5
|
+
A [Belt](https://github.com/stowzilla/belt) plugin. Add the gem to a Belt app, then run the generator to install infrastructure and app wiring.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
Add to your Belt app's Gemfile:
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
gem '<%= gem_name %>'
|
|
13
|
+
# Or during development:
|
|
14
|
+
# gem '<%= gem_name %>', path: '../<%= gem_name %>'
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Then:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
bundle install
|
|
21
|
+
belt generate <%= plugin_name %>
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## What You Get
|
|
25
|
+
|
|
26
|
+
### From the gem (no generation needed)
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
# Configure
|
|
30
|
+
<%= constant_path %>.configure do |config|
|
|
31
|
+
# config.option = value
|
|
32
|
+
end
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### From the generator (`belt g <%= plugin_name %>`)
|
|
36
|
+
|
|
37
|
+
Edit `lib/belt/generators/<%= underscored %>_generator.rb` and templates under
|
|
38
|
+
`lib/belt/<%= underscored %>/templates/` to define what gets installed into apps.
|
|
39
|
+
|
|
40
|
+
Typical plugin generators create some combination of:
|
|
41
|
+
|
|
42
|
+
- **Terraform module** — `infrastructure/modules/<%= plugin_name %>/`
|
|
43
|
+
- **Lambda entry point** — `lambda/<%= plugin_name %>.rb`
|
|
44
|
+
- **Lambda config** — `config/lambda/<%= plugin_name %>.yml`
|
|
45
|
+
- **Route / schema injection** — updates to `routes.tf.rb` / `schema.tf.rb`
|
|
46
|
+
- **Optional controller overrides** — `belt g <%= plugin_name %> --controllers`
|
|
47
|
+
|
|
48
|
+
## Development
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
bundle install
|
|
52
|
+
bundle exec rspec
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Removing
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
belt destroy <%= plugin_name %>
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## License
|
|
62
|
+
|
|
63
|
+
MIT
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'lib/belt/<%= underscored %>/version'
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = '<%= gem_name %>'
|
|
7
|
+
spec.version = <%= constant_path %>::VERSION
|
|
8
|
+
spec.authors = ['Your Name']
|
|
9
|
+
spec.email = ['you@example.com']
|
|
10
|
+
|
|
11
|
+
spec.summary = '<%= summary %>'
|
|
12
|
+
spec.description = '<%= summary %>. A Belt plugin gem — add to a Belt app Gemfile, then run `belt generate <%= plugin_name %>`.'
|
|
13
|
+
spec.homepage = 'https://github.com/example/<%= gem_name %>'
|
|
14
|
+
spec.license = 'MIT'
|
|
15
|
+
spec.required_ruby_version = '>= 3.3'
|
|
16
|
+
|
|
17
|
+
spec.metadata['homepage_uri'] = spec.homepage
|
|
18
|
+
spec.metadata['source_code_uri'] = spec.homepage
|
|
19
|
+
spec.metadata['changelog_uri'] = "#{spec.homepage}/blob/main/CHANGELOG.md"
|
|
20
|
+
spec.metadata['rubygems_mfa_required'] = 'true'
|
|
21
|
+
|
|
22
|
+
spec.files = Dir['lib/**/*', 'LICENSE', 'README.md', 'CHANGELOG.md']
|
|
23
|
+
spec.require_paths = ['lib']
|
|
24
|
+
|
|
25
|
+
spec.add_dependency 'belt', '~> 0.2'
|
|
26
|
+
end
|