belt 0.2.10 → 0.2.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 +4 -4
- data/CHANGELOG.md +63 -0
- data/README.md +169 -0
- data/lib/belt/action_router.rb +32 -4
- data/lib/belt/cli/app_detection.rb +19 -5
- data/lib/belt/cli/contracts_command.rb +142 -0
- data/lib/belt/cli/destroy_command.rb +193 -10
- data/lib/belt/cli/environment_command.rb +32 -0
- data/lib/belt/cli/generate_command.rb +5 -5
- data/lib/belt/cli/new_command.rb +2 -2
- data/lib/belt/cli/plugin_command.rb +221 -0
- data/lib/belt/cli/routes_command/schema_loader.rb +17 -7
- data/lib/belt/cli/routes_command.rb +7 -3
- data/lib/belt/cli/setup_command.rb +1 -1
- data/lib/belt/cli/views_command.rb +7 -2
- data/lib/belt/cli.rb +7 -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/root.rb +12 -3
- data/lib/belt/route_dsl.rb +2 -2
- data/lib/belt/version.rb +1 -1
- data/lib/templates/environment/backend.tf.erb +1 -2
- data/lib/templates/frontend_infra/frontend.tf.erb +2 -1
- data/lib/templates/module/frontend.tf.erb +2 -1
- data/lib/templates/module/main.tf.erb +1 -1
- data/lib/templates/new_app/AGENTS.md.erb +4 -4
- data/lib/templates/new_app/README.md.erb +2 -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 +21 -3
- /data/lib/templates/new_app/config/{schema.tf.rb.erb → contracts.rb.erb} +0 -0
- /data/lib/templates/new_app/config/{routes.tf.rb.erb → routes.rb.erb} +0 -0
|
@@ -44,10 +44,11 @@ module Belt
|
|
|
44
44
|
when 'environment'
|
|
45
45
|
name = args.shift
|
|
46
46
|
if name.nil? || name.empty?
|
|
47
|
-
puts 'Usage: belt destroy environment <name>'
|
|
47
|
+
puts 'Usage: belt destroy environment <name> [--force] [--skip-terraform]'
|
|
48
48
|
exit 1
|
|
49
49
|
end
|
|
50
|
-
|
|
50
|
+
flags = parse_environment_flags(args)
|
|
51
|
+
new(generator, name, [], **flags).destroy
|
|
51
52
|
when 'frontend'
|
|
52
53
|
new(generator, nil, []).destroy
|
|
53
54
|
when 'views'
|
|
@@ -67,6 +68,19 @@ module Belt
|
|
|
67
68
|
end
|
|
68
69
|
end
|
|
69
70
|
|
|
71
|
+
def self.parse_environment_flags(args)
|
|
72
|
+
flags = { force: false, skip_terraform: false }
|
|
73
|
+
args.each do |arg|
|
|
74
|
+
case arg
|
|
75
|
+
when '--force', '-f'
|
|
76
|
+
flags[:force] = true
|
|
77
|
+
when '--skip-terraform'
|
|
78
|
+
flags[:skip_terraform] = true
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
flags
|
|
82
|
+
end
|
|
83
|
+
|
|
70
84
|
def self.parse_field(arg)
|
|
71
85
|
name, type = arg.split(':', 2)
|
|
72
86
|
{ name: name, type: type || 'string' }
|
|
@@ -84,26 +98,37 @@ module Belt
|
|
|
84
98
|
resource Alias for scaffold
|
|
85
99
|
model Remove an ActiveItem model
|
|
86
100
|
controller Remove a controller
|
|
87
|
-
environment Remove a deployment environment
|
|
101
|
+
environment Remove a deployment environment and tear down infrastructure
|
|
88
102
|
frontend Remove the frontend/ directory
|
|
89
103
|
views Remove React pages for a resource
|
|
90
104
|
|
|
105
|
+
Environment options:
|
|
106
|
+
--force, -f Skip all prompts (CI mode)
|
|
107
|
+
--skip-terraform Delete local files without running terraform destroy
|
|
108
|
+
|
|
91
109
|
Examples:
|
|
92
110
|
belt d scaffold post title:string body:text status:string
|
|
93
111
|
belt d model user
|
|
94
112
|
belt d controller comments
|
|
95
113
|
belt d environment staging
|
|
114
|
+
belt d environment dev --skip-terraform
|
|
115
|
+
belt d environment dev --force
|
|
96
116
|
belt d frontend
|
|
97
117
|
belt d views post
|
|
98
118
|
|
|
99
119
|
⚠ This is destructive. Files will be permanently deleted.
|
|
120
|
+
|
|
121
|
+
For environments: if terraform state is detected, you'll be prompted to run
|
|
122
|
+
`terraform destroy` first to tear down cloud resources before removing files.
|
|
100
123
|
HELP
|
|
101
124
|
end
|
|
102
125
|
|
|
103
|
-
def initialize(generator, name, fields)
|
|
126
|
+
def initialize(generator, name, fields, force: false, skip_terraform: false)
|
|
104
127
|
@generator = generator
|
|
105
128
|
@name = name&.downcase&.gsub(/[^a-z0-9_]/, '_')
|
|
106
129
|
@fields = fields
|
|
130
|
+
@force = force
|
|
131
|
+
@skip_terraform = skip_terraform
|
|
107
132
|
@app_name = detect_namespace
|
|
108
133
|
@singular_name = @name ? Belt::Inflector.singularize(@name) : nil
|
|
109
134
|
@resource_name = @singular_name ? Belt::Inflector.pluralize(@singular_name) : nil
|
|
@@ -149,15 +174,173 @@ module Belt
|
|
|
149
174
|
|
|
150
175
|
def destroy_environment
|
|
151
176
|
dir = "infrastructure/#{@name}"
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
@removed << dir
|
|
155
|
-
puts " remove #{dir}/"
|
|
156
|
-
puts "\n✓ Environment '#{@name}' destroyed!"
|
|
157
|
-
else
|
|
177
|
+
|
|
178
|
+
unless Dir.exist?(dir)
|
|
158
179
|
puts "✗ Environment '#{@name}' not found at #{dir}/"
|
|
159
180
|
exit 1
|
|
160
181
|
end
|
|
182
|
+
|
|
183
|
+
# Check if terraform state exists (infra may still be live)
|
|
184
|
+
if !@skip_terraform && terraform_state_exists?(dir)
|
|
185
|
+
puts "⚠ Environment '#{@name}' appears to have active infrastructure."
|
|
186
|
+
puts " Terraform state was found — resources may still be running.\n\n"
|
|
187
|
+
|
|
188
|
+
if @force
|
|
189
|
+
puts ' --force passed, skipping terraform destroy.'
|
|
190
|
+
else
|
|
191
|
+
puts ' Options:'
|
|
192
|
+
puts ' 1) Run `terraform destroy` to tear down infrastructure first (recommended)'
|
|
193
|
+
puts ' 2) Skip terraform and just delete the local files (--skip-terraform)'
|
|
194
|
+
puts " 3) Cancel\n\n"
|
|
195
|
+
|
|
196
|
+
print " Run terraform destroy for '#{@name}'? [y/N/skip] "
|
|
197
|
+
response = $stdin.gets&.strip&.downcase
|
|
198
|
+
|
|
199
|
+
case response
|
|
200
|
+
when 'y', 'yes'
|
|
201
|
+
run_terraform_destroy(dir)
|
|
202
|
+
when 'skip', 's'
|
|
203
|
+
puts ' Skipping terraform destroy.'
|
|
204
|
+
else
|
|
205
|
+
puts 'Cancelled.'
|
|
206
|
+
exit 0
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Final confirmation before deleting files
|
|
212
|
+
unless @force
|
|
213
|
+
print "\nPermanently delete #{dir}/? [y/N] "
|
|
214
|
+
response = $stdin.gets&.strip&.downcase
|
|
215
|
+
unless response&.start_with?('y')
|
|
216
|
+
puts 'Cancelled.'
|
|
217
|
+
exit 0
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
FileUtils.rm_rf(dir)
|
|
222
|
+
@removed << dir
|
|
223
|
+
puts " remove #{dir}/"
|
|
224
|
+
puts "\n✓ Environment '#{@name}' destroyed!"
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def terraform_state_exists?(dir)
|
|
228
|
+
# Check for local .terraform directory (initialized state)
|
|
229
|
+
return true if Dir.exist?(File.join(dir, '.terraform'))
|
|
230
|
+
|
|
231
|
+
# Check if backend.tf exists (remote state configured)
|
|
232
|
+
backend_file = File.join(dir, 'backend.tf')
|
|
233
|
+
return false unless File.exist?(backend_file)
|
|
234
|
+
|
|
235
|
+
# Try to query remote state — if terraform is initialized and state exists,
|
|
236
|
+
# the environment likely has live resources
|
|
237
|
+
Dir.chdir(dir) do
|
|
238
|
+
# Quick check: does `terraform show` return anything?
|
|
239
|
+
output = `terraform show -no-color 2>&1`
|
|
240
|
+
Process.last_status.success? && !output.strip.empty? && !output.include?('No state')
|
|
241
|
+
end
|
|
242
|
+
rescue StandardError
|
|
243
|
+
# If we can't determine state, assume it might exist and warn
|
|
244
|
+
true
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def run_terraform_destroy(dir)
|
|
248
|
+
puts "\n━━━ terraform destroy (#{@name}) ━━━"
|
|
249
|
+
|
|
250
|
+
Dir.chdir(dir) do
|
|
251
|
+
# Initialize if needed
|
|
252
|
+
unless Dir.exist?('.terraform')
|
|
253
|
+
puts ' Initializing terraform...'
|
|
254
|
+
unless system('terraform', 'init', '-input=false')
|
|
255
|
+
puts "\n✗ terraform init failed. You may need to destroy manually:"
|
|
256
|
+
puts " cd #{dir} && terraform init && terraform destroy"
|
|
257
|
+
exit 1
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Empty S3 buckets before destroy — terraform can't delete non-empty buckets
|
|
262
|
+
empty_s3_buckets_in_state
|
|
263
|
+
|
|
264
|
+
# Run destroy with auto-approve (user already confirmed)
|
|
265
|
+
unless system('terraform', 'destroy', '-auto-approve')
|
|
266
|
+
puts "\n✗ terraform destroy failed."
|
|
267
|
+
puts ' Infrastructure may still be running. Fix and retry, or use --skip-terraform.'
|
|
268
|
+
exit 1
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
puts ' ✓ Infrastructure destroyed.'
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def empty_s3_buckets_in_state
|
|
276
|
+
output = `terraform state list 2>/dev/null`
|
|
277
|
+
return unless Process.last_status.success?
|
|
278
|
+
|
|
279
|
+
bucket_resources = output.lines.map(&:strip).grep(/\Aaws_s3_bucket\./)
|
|
280
|
+
return if bucket_resources.empty?
|
|
281
|
+
|
|
282
|
+
bucket_resources.each do |resource|
|
|
283
|
+
bucket_name = resolve_bucket_name(resource)
|
|
284
|
+
next unless bucket_name
|
|
285
|
+
|
|
286
|
+
empty_s3_bucket(bucket_name)
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def resolve_bucket_name(resource)
|
|
291
|
+
output = `terraform state show '#{resource}' 2>/dev/null`
|
|
292
|
+
return nil unless Process.last_status.success?
|
|
293
|
+
|
|
294
|
+
match = output.match(/^\s*bucket\s*=\s*"([^"]+)"/)
|
|
295
|
+
match&.[](1)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def empty_s3_bucket(bucket_name)
|
|
299
|
+
# Check if bucket exists
|
|
300
|
+
`aws s3api head-bucket --bucket '#{bucket_name}' 2>&1`
|
|
301
|
+
return unless Process.last_status.success?
|
|
302
|
+
|
|
303
|
+
puts " Emptying S3 bucket: #{bucket_name}"
|
|
304
|
+
|
|
305
|
+
# Delete all object versions (handles versioned buckets)
|
|
306
|
+
`aws s3 rm 's3://#{bucket_name}' --recursive 2>/dev/null`
|
|
307
|
+
|
|
308
|
+
# Also delete versioned objects and delete markers
|
|
309
|
+
delete_all_versions(bucket_name)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def delete_all_versions(bucket_name)
|
|
313
|
+
loop do
|
|
314
|
+
output = `aws s3api list-object-versions --bucket '#{bucket_name}' --max-items 1000 2>/dev/null`
|
|
315
|
+
break unless Process.last_status.success?
|
|
316
|
+
|
|
317
|
+
begin
|
|
318
|
+
data = JSON.parse(output)
|
|
319
|
+
rescue JSON::ParserError
|
|
320
|
+
break
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
versions = (data['Versions'] || []) + (data['DeleteMarkers'] || [])
|
|
324
|
+
break if versions.empty?
|
|
325
|
+
|
|
326
|
+
# Build delete objects payload
|
|
327
|
+
objects = versions.map do |v|
|
|
328
|
+
{ 'Key' => v['Key'], 'VersionId' => v['VersionId'] }
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
delete_payload = JSON.generate({ 'Objects' => objects, 'Quiet' => true })
|
|
332
|
+
|
|
333
|
+
# Use a temp file for the payload since it can be large
|
|
334
|
+
require 'tempfile'
|
|
335
|
+
Tempfile.create(['delete-objects', '.json']) do |f|
|
|
336
|
+
f.write(delete_payload)
|
|
337
|
+
f.flush
|
|
338
|
+
`aws s3api delete-objects --bucket '#{bucket_name}' --delete 'file://#{f.path}' 2>/dev/null`
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# If there's no next token, we're done
|
|
342
|
+
break unless data['NextToken'] || data['IsTruncated']
|
|
343
|
+
end
|
|
161
344
|
end
|
|
162
345
|
|
|
163
346
|
def destroy_frontend
|
|
@@ -32,6 +32,7 @@ module Belt
|
|
|
32
32
|
@domain = domain
|
|
33
33
|
@quiet = quiet
|
|
34
34
|
@announce = announce
|
|
35
|
+
@state_bucket = resolve_state_bucket
|
|
35
36
|
end
|
|
36
37
|
|
|
37
38
|
def generate
|
|
@@ -80,6 +81,37 @@ module Belt
|
|
|
80
81
|
content = ERB.new(File.read(template_path), trim_mode: '-').result(binding)
|
|
81
82
|
File.write(dest_path, content)
|
|
82
83
|
end
|
|
84
|
+
|
|
85
|
+
# Resolve the state bucket name to use in backend.tf.
|
|
86
|
+
# Priority: existing sibling backend.tf → AWS account ID → bare placeholder.
|
|
87
|
+
def resolve_state_bucket
|
|
88
|
+
bucket_from_sibling || bucket_from_aws || 'belt-terraform-state'
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def bucket_from_sibling
|
|
92
|
+
Dir.glob('infrastructure/*/backend.tf').each do |f|
|
|
93
|
+
match = File.read(f).match(/bucket\s*=\s*"([^"]+)"/)
|
|
94
|
+
next unless match
|
|
95
|
+
# Skip the bare placeholder — it means state wasn't set up yet
|
|
96
|
+
return match[1] unless match[1] == 'belt-terraform-state'
|
|
97
|
+
end
|
|
98
|
+
nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def bucket_from_aws
|
|
102
|
+
require 'open3'
|
|
103
|
+
output, status = Open3.capture2e('aws', 'sts', 'get-caller-identity')
|
|
104
|
+
return nil unless status.success?
|
|
105
|
+
|
|
106
|
+
data = begin
|
|
107
|
+
JSON.parse(output)
|
|
108
|
+
rescue StandardError
|
|
109
|
+
nil
|
|
110
|
+
end
|
|
111
|
+
return nil unless data&.dig('Account')
|
|
112
|
+
|
|
113
|
+
"belt-terraform-state-#{data['Account']}"
|
|
114
|
+
end
|
|
83
115
|
end
|
|
84
116
|
end
|
|
85
117
|
end
|
|
@@ -38,8 +38,8 @@ module Belt
|
|
|
38
38
|
Creates:
|
|
39
39
|
lambda/models/<name>.rb Model with validations and fields
|
|
40
40
|
lambda/controllers/<app>/<names>_controller.rb RESTful controller (index, show, create, update, destroy)
|
|
41
|
-
config/routes.
|
|
42
|
-
config/
|
|
41
|
+
config/routes.rb Route entry added
|
|
42
|
+
config/contracts.rb API response contract added
|
|
43
43
|
lambda/lib/routes/<app>_routes.rb Route manifest updated
|
|
44
44
|
infrastructure/modules/app/dynamodb.tf DynamoDB table generated
|
|
45
45
|
frontend/src/pages/<names>/ React pages (if frontend exists)
|
|
@@ -60,7 +60,7 @@ module Belt
|
|
|
60
60
|
notes: <<~NOTES
|
|
61
61
|
Creates:
|
|
62
62
|
lambda/models/<name>.rb Model class inheriting from ApplicationRecord
|
|
63
|
-
config/
|
|
63
|
+
config/contracts.rb API response contract added
|
|
64
64
|
infrastructure/modules/app/dynamodb.tf DynamoDB table generated
|
|
65
65
|
NOTES
|
|
66
66
|
},
|
|
@@ -333,8 +333,8 @@ module Belt
|
|
|
333
333
|
puts "\nFiles created/updated:"
|
|
334
334
|
puts " lambda/models/#{@singular_name}.rb"
|
|
335
335
|
puts " lambda/controllers/#{@app_name}/#{@resource_name}_controller.rb"
|
|
336
|
-
puts " #{find_routes_file_path || 'config/routes.
|
|
337
|
-
puts " #{
|
|
336
|
+
puts " #{find_routes_file_path || 'config/routes.rb'} (updated)"
|
|
337
|
+
puts " #{find_contracts_file_path || 'config/contracts.rb'} (updated)"
|
|
338
338
|
puts " lambda/lib/routes/#{@app_name}_routes.rb (updated)"
|
|
339
339
|
puts " frontend/src/pages/#{@resource_name}/ (views)" if Dir.exist?('frontend/src')
|
|
340
340
|
end
|
data/lib/belt/cli/new_command.rb
CHANGED
|
@@ -167,8 +167,8 @@ module Belt
|
|
|
167
167
|
'lambda/controllers/application_controller.rb.erb' =>
|
|
168
168
|
"#{@app_name}/lambda/controllers/api/application_controller.rb",
|
|
169
169
|
'lambda/lib/routes/routes.rb.erb' => "#{@app_name}/lambda/lib/routes/api_routes.rb",
|
|
170
|
-
'config/routes.
|
|
171
|
-
'config/
|
|
170
|
+
'config/routes.rb.erb' => "#{@app_name}/config/routes.rb",
|
|
171
|
+
'config/contracts.rb.erb' => "#{@app_name}/config/contracts.rb",
|
|
172
172
|
'config/lambda/api.yml.erb' => "#{@app_name}/config/lambda/api.yml",
|
|
173
173
|
'README.md.erb' => "#{@app_name}/README.md",
|
|
174
174
|
'AGENTS.md.erb' => "#{@app_name}/AGENTS.md",
|
|
@@ -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
|
|
@@ -8,14 +8,14 @@ module Belt
|
|
|
8
8
|
private
|
|
9
9
|
|
|
10
10
|
def load_schema_models(routes_file)
|
|
11
|
-
schema_file =
|
|
11
|
+
schema_file = resolve_contracts_file(routes_file)
|
|
12
12
|
return [] unless schema_file && File.exist?(schema_file)
|
|
13
13
|
|
|
14
14
|
Belt.instance_variable_set(:@application, nil)
|
|
15
15
|
begin
|
|
16
16
|
eval(File.read(schema_file), binding, schema_file) # rubocop:disable Security/Eval
|
|
17
17
|
rescue StandardError => e
|
|
18
|
-
warn "Warning: Failed to load
|
|
18
|
+
warn "Warning: Failed to load contracts file #{schema_file}: #{e.message}"
|
|
19
19
|
return []
|
|
20
20
|
end
|
|
21
21
|
|
|
@@ -23,15 +23,25 @@ module Belt
|
|
|
23
23
|
build_models_from_schema(schema)
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
-
def
|
|
26
|
+
def resolve_contracts_file(routes_file)
|
|
27
27
|
schema_file = @options[:schema_file]
|
|
28
28
|
unless schema_file
|
|
29
29
|
routes_dir = File.dirname(File.expand_path(routes_file))
|
|
30
|
-
|
|
30
|
+
# Check new convention first, then legacy names
|
|
31
|
+
candidates = [
|
|
32
|
+
File.join(routes_dir, 'contracts.rb'),
|
|
33
|
+
File.join(routes_dir, 'contracts.tf.rb'),
|
|
34
|
+
File.join(routes_dir, 'schema.tf.rb')
|
|
35
|
+
]
|
|
36
|
+
schema_file = candidates.find { |f| File.exist?(f) }
|
|
37
|
+
|
|
31
38
|
# Fall back to infrastructure/ if not found in same directory as routes
|
|
32
|
-
unless
|
|
33
|
-
|
|
34
|
-
|
|
39
|
+
unless schema_file
|
|
40
|
+
legacy_candidates = [
|
|
41
|
+
'infrastructure/contracts.rb',
|
|
42
|
+
'infrastructure/schema.tf.rb'
|
|
43
|
+
]
|
|
44
|
+
schema_file = legacy_candidates.find { |f| File.exist?(f) }
|
|
35
45
|
end
|
|
36
46
|
end
|
|
37
47
|
schema_file
|
|
@@ -27,7 +27,7 @@ module Belt
|
|
|
27
27
|
routes_file = find_routes_file
|
|
28
28
|
unless routes_file
|
|
29
29
|
abort 'Error: No routes file found. ' \
|
|
30
|
-
'Expected config/routes.
|
|
30
|
+
'Expected config/routes.rb (or config/routes.tf.rb, infrastructure/routes.tf.rb)'
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
dsl = load_routes(routes_file)
|
|
@@ -71,7 +71,7 @@ module Belt
|
|
|
71
71
|
@options[:output_dir] = dir
|
|
72
72
|
end
|
|
73
73
|
|
|
74
|
-
opts.on('--schema FILE', 'Path to
|
|
74
|
+
opts.on('--schema FILE', 'Path to contracts.rb for model definitions') do |file|
|
|
75
75
|
@options[:schema_file] = file
|
|
76
76
|
end
|
|
77
77
|
|
|
@@ -87,7 +87,11 @@ module Belt
|
|
|
87
87
|
end
|
|
88
88
|
|
|
89
89
|
def find_routes_file
|
|
90
|
-
candidates = [
|
|
90
|
+
candidates = [
|
|
91
|
+
'config/routes.rb',
|
|
92
|
+
'config/routes.tf.rb',
|
|
93
|
+
'infrastructure/routes.tf.rb'
|
|
94
|
+
]
|
|
91
95
|
candidates.find { |f| File.exist?(f) }
|
|
92
96
|
end
|
|
93
97
|
|
|
@@ -32,7 +32,7 @@ module Belt
|
|
|
32
32
|
puts 'Usage: belt setup <state|tables|frontend> [options]'
|
|
33
33
|
puts "\nSubcommands:"
|
|
34
34
|
puts ' state Set up S3 bucket for Terraform state'
|
|
35
|
-
puts ' tables Generate DynamoDB table definitions from
|
|
35
|
+
puts ' tables Generate DynamoDB table definitions from contracts.rb'
|
|
36
36
|
puts ' frontend Generate S3 + CloudFront infrastructure for frontend hosting'
|
|
37
37
|
exit 1
|
|
38
38
|
end
|
|
@@ -25,14 +25,19 @@ module Belt
|
|
|
25
25
|
{ name: n, type: t || 'string' }
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
-
# If no fields provided, try to read from
|
|
28
|
+
# If no fields provided, try to read from contracts.rb
|
|
29
29
|
fields = read_schema_fields(name) if fields.empty?
|
|
30
30
|
|
|
31
31
|
new(name, fields).generate
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
def self.read_schema_fields(name)
|
|
35
|
-
schema_file = [
|
|
35
|
+
schema_file = [
|
|
36
|
+
'config/contracts.rb',
|
|
37
|
+
'config/contracts.tf.rb',
|
|
38
|
+
'config/schema.tf.rb',
|
|
39
|
+
'infrastructure/schema.tf.rb'
|
|
40
|
+
].find { |f| File.exist?(f) }
|
|
36
41
|
return [] unless schema_file
|
|
37
42
|
|
|
38
43
|
content = File.read(schema_file)
|