belt 0.2.9 → 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 +119 -0
- data/README.md +217 -8
- data/lib/belt/action_router.rb +33 -5
- data/lib/belt/assets/welcome.css +119 -47
- data/lib/belt/cli/bucket_security.rb +10 -4
- data/lib/belt/cli/deploy_command.rb +48 -0
- data/lib/belt/cli/destroy_command.rb +119 -10
- data/lib/belt/cli/doctor_command.rb +208 -0
- data/lib/belt/cli/environment_command.rb +38 -6
- data/lib/belt/cli/frontend_command.rb +25 -12
- data/lib/belt/cli/frontend_setup_command.rb +41 -0
- data/lib/belt/cli/new_command.rb +106 -38
- data/lib/belt/cli/path_gem_materializer.rb +141 -0
- data/lib/belt/cli/plugin_command.rb +221 -0
- data/lib/belt/cli/setup_command.rb +35 -10
- data/lib/belt/cli.rb +8 -0
- data/lib/belt/configuration.rb +30 -0
- data/lib/belt/controllers/welcome_controller.rb +69 -2
- data/lib/belt/helpers/cors_origin.rb +19 -2
- data/lib/belt/helpers/response.rb +48 -6
- data/lib/belt/http_status.rb +89 -0
- data/lib/belt/lambda_handler.rb +14 -5
- data/lib/belt/rendering.rb +1 -0
- data/lib/belt/version.rb +1 -1
- data/lib/belt/views/welcome/show.html.erb +31 -31
- data/lib/belt.rb +16 -0
- data/lib/belt_controller/base.rb +24 -1
- data/lib/belt_controller/implicit_response.rb +104 -0
- data/lib/templates/environment/backend.tf.erb +1 -1
- data/lib/templates/frontend/react/src/index.css +4 -4
- data/lib/templates/frontend/react/src/lib/apiClient.js.erb +20 -3
- data/lib/templates/frontend/react/src/pages/Home.jsx.erb +283 -4
- data/lib/templates/module/main.tf.erb +13 -3
- data/lib/templates/new_app/config/routes.tf.rb.erb +2 -1
- 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 +37 -1
|
@@ -28,6 +28,7 @@ module Belt
|
|
|
28
28
|
def run
|
|
29
29
|
validate!
|
|
30
30
|
generate_frontend_tf
|
|
31
|
+
ensure_cloudfront_cors
|
|
31
32
|
return if @quiet
|
|
32
33
|
|
|
33
34
|
puts "\n✓ Frontend infrastructure generated in #{MODULE_DIR}!"
|
|
@@ -51,6 +52,46 @@ module Belt
|
|
|
51
52
|
File.write(dest, content)
|
|
52
53
|
puts " create #{dest}" unless @quiet
|
|
53
54
|
end
|
|
55
|
+
|
|
56
|
+
# Wire CloudFront origin into conveyor_belt frontend_urls so SPA→API CORS works.
|
|
57
|
+
# Handles common scaffold shapes so users never need the tutorial's manual CORS fix.
|
|
58
|
+
def ensure_cloudfront_cors
|
|
59
|
+
main_tf = File.join(MODULE_DIR, 'main.tf')
|
|
60
|
+
return unless File.exist?(main_tf)
|
|
61
|
+
|
|
62
|
+
content = File.read(main_tf)
|
|
63
|
+
return if content.include?('aws_cloudfront_distribution.frontend.domain_name')
|
|
64
|
+
|
|
65
|
+
replacement = lambda do |indent|
|
|
66
|
+
<<~TF.chomp
|
|
67
|
+
#{indent}# CloudFront first so SPA→API CORS works out of the box.
|
|
68
|
+
#{indent}frontend_urls = concat(
|
|
69
|
+
#{indent} ["https://${aws_cloudfront_distribution.frontend.domain_name}"],
|
|
70
|
+
#{indent} var.frontend_urls
|
|
71
|
+
#{indent})
|
|
72
|
+
TF
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# `frontend_urls = var.frontend_urls`
|
|
76
|
+
simple = /^(\s*)frontend_urls\s*=\s*var\.frontend_urls\s*$/
|
|
77
|
+
if content.match?(simple)
|
|
78
|
+
content = content.sub(simple) { replacement.call(Regexp.last_match(1)) }
|
|
79
|
+
File.write(main_tf, content)
|
|
80
|
+
puts " update #{main_tf} (CloudFront CORS)" unless @quiet
|
|
81
|
+
return
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# `frontend_urls = concat(var.frontend_urls)` or multi-line concat without CloudFront
|
|
85
|
+
concat_only = /^(\s*)frontend_urls\s*=\s*concat\(\s*\n?\s*var\.frontend_urls\s*\n?\s*\)\s*$/m
|
|
86
|
+
if content.match?(concat_only)
|
|
87
|
+
content = content.sub(concat_only) { replacement.call(Regexp.last_match(1)) }
|
|
88
|
+
File.write(main_tf, content)
|
|
89
|
+
puts " update #{main_tf} (CloudFront CORS)" unless @quiet
|
|
90
|
+
return
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
puts " skip #{main_tf} (add CloudFront to frontend_urls manually for CORS)" unless @quiet
|
|
94
|
+
end
|
|
54
95
|
end
|
|
55
96
|
end
|
|
56
97
|
end
|
data/lib/belt/cli/new_command.rb
CHANGED
|
@@ -18,6 +18,7 @@ module Belt
|
|
|
18
18
|
bucket = nil
|
|
19
19
|
environments = nil
|
|
20
20
|
domain = nil
|
|
21
|
+
verbose = false
|
|
21
22
|
|
|
22
23
|
i = 0
|
|
23
24
|
while i < args.length
|
|
@@ -49,6 +50,8 @@ module Belt
|
|
|
49
50
|
domain = args[i]
|
|
50
51
|
when /^--domain=/
|
|
51
52
|
domain = arg.split('=', 2).last
|
|
53
|
+
when '-v', '--verbose'
|
|
54
|
+
verbose = true
|
|
52
55
|
else
|
|
53
56
|
app_name ||= arg unless arg.start_with?('-')
|
|
54
57
|
end
|
|
@@ -65,22 +68,33 @@ module Belt
|
|
|
65
68
|
puts ' --state-bucket BUCKET_NAME Alias for --bucket'
|
|
66
69
|
puts ' --environments dev,prod Comma-separated environments (default: dev,prod)'
|
|
67
70
|
puts ' Use "none" to skip environment setup'
|
|
71
|
+
puts ' -v, --verbose List every created file (Rails-style)'
|
|
68
72
|
exit 1
|
|
69
73
|
end
|
|
70
74
|
|
|
71
|
-
new(
|
|
75
|
+
new(
|
|
76
|
+
app_name,
|
|
77
|
+
frontend: frontend,
|
|
78
|
+
bucket: bucket,
|
|
79
|
+
environments: environments,
|
|
80
|
+
domain: domain,
|
|
81
|
+
verbose: verbose
|
|
82
|
+
).generate
|
|
72
83
|
end
|
|
73
84
|
|
|
74
|
-
|
|
85
|
+
# rubocop:disable Metrics/ParameterLists -- keyword options for generator flags
|
|
86
|
+
def initialize(app_name, frontend: nil, bucket: nil, environments: nil, domain: nil, verbose: false)
|
|
75
87
|
@app_name = app_name.gsub(/[^a-z0-9_-]/i, '_').downcase
|
|
76
88
|
@module_name = @app_name.split(/[-_]/).map(&:capitalize).join
|
|
77
89
|
@frontend = frontend
|
|
78
90
|
@bucket = bucket
|
|
79
91
|
@domain = domain
|
|
80
92
|
@environments = parse_environments(environments)
|
|
93
|
+
@verbose = verbose
|
|
81
94
|
@resolved_bucket = nil
|
|
82
95
|
@state_setup_succeeded = false
|
|
83
96
|
end
|
|
97
|
+
# rubocop:enable Metrics/ParameterLists
|
|
84
98
|
|
|
85
99
|
def generate
|
|
86
100
|
if Dir.exist?(@app_name)
|
|
@@ -88,9 +102,12 @@ module Belt
|
|
|
88
102
|
exit 1
|
|
89
103
|
end
|
|
90
104
|
|
|
105
|
+
preflight_check
|
|
106
|
+
|
|
91
107
|
puts "Creating new Belt application: #{@app_name}"
|
|
92
108
|
create_structure
|
|
93
109
|
generate_module
|
|
110
|
+
puts ' create app skeleton' unless @verbose
|
|
94
111
|
generate_environments
|
|
95
112
|
generate_frontend if @frontend
|
|
96
113
|
init_git
|
|
@@ -110,6 +127,19 @@ module Belt
|
|
|
110
127
|
env_string.split(',').map(&:strip).reject(&:empty?)
|
|
111
128
|
end
|
|
112
129
|
|
|
130
|
+
def preflight_check
|
|
131
|
+
missing = []
|
|
132
|
+
{ 'aws' => 'AWS CLI', 'terraform' => 'Terraform' }.each do |cmd, label|
|
|
133
|
+
_, status = Open3.capture2e(cmd, '--version')
|
|
134
|
+
missing << label unless status.success?
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
return if missing.empty?
|
|
138
|
+
|
|
139
|
+
puts "⚠ Missing: #{missing.join(', ')}"
|
|
140
|
+
puts " Belt requires these tools to deploy. Install them, then run `belt doctor` to verify.\n\n"
|
|
141
|
+
end
|
|
142
|
+
|
|
113
143
|
def create_structure
|
|
114
144
|
directories.each { |dir| create_dir(dir) }
|
|
115
145
|
files.each { |src, dest| create_file(src, dest) }
|
|
@@ -162,7 +192,7 @@ module Belt
|
|
|
162
192
|
template_path = File.join(module_template_dir, template_name)
|
|
163
193
|
content = ERB.new(File.read(template_path), trim_mode: '-').result(binding)
|
|
164
194
|
File.write(dest_path, content)
|
|
165
|
-
puts " create #{dest_path}"
|
|
195
|
+
puts " create #{dest_path}" if @verbose
|
|
166
196
|
end
|
|
167
197
|
end
|
|
168
198
|
|
|
@@ -171,43 +201,62 @@ module Belt
|
|
|
171
201
|
|
|
172
202
|
Dir.chdir(@app_name) do
|
|
173
203
|
@environments.each do |env_name|
|
|
174
|
-
Belt::CLI::EnvironmentCommand.new(
|
|
204
|
+
Belt::CLI::EnvironmentCommand.new(
|
|
205
|
+
env_name,
|
|
206
|
+
quiet: !@verbose,
|
|
207
|
+
announce: false,
|
|
208
|
+
domain: @domain
|
|
209
|
+
).generate
|
|
175
210
|
end
|
|
176
211
|
end
|
|
212
|
+
puts " create environments (#{@environments.join(', ')})" unless @verbose
|
|
177
213
|
end
|
|
178
214
|
|
|
179
215
|
def setup_state
|
|
180
216
|
return if @environments.empty?
|
|
181
217
|
|
|
182
|
-
Dir.chdir(@app_name)
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
puts " #{@aws_error}" if @aws_error
|
|
202
|
-
else
|
|
203
|
-
puts ' ⚠ AWS credentials not detected — skipping state bucket creation.'
|
|
204
|
-
end
|
|
205
|
-
puts ' Run `belt setup state` after configuring credentials (aws sso login / AWS_PROFILE).'
|
|
206
|
-
@state_setup_succeeded = false
|
|
207
|
-
end
|
|
218
|
+
Dir.chdir(@app_name) { create_or_skip_state_bucket }
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def create_or_skip_state_bucket
|
|
222
|
+
# Shared bucket: one per AWS account, all belt apps share it.
|
|
223
|
+
# Account ID suffix makes the name globally unique (S3 is a global namespace).
|
|
224
|
+
@resolved_bucket = if @bucket
|
|
225
|
+
@bucket
|
|
226
|
+
elsif aws_configured?
|
|
227
|
+
"belt-terraform-state-#{@aws_account_id}"
|
|
228
|
+
else
|
|
229
|
+
'belt-terraform-state'
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
if aws_configured?
|
|
233
|
+
create_state_bucket!
|
|
234
|
+
else
|
|
235
|
+
warn_missing_aws_for_state
|
|
236
|
+
@state_setup_succeeded = false
|
|
208
237
|
end
|
|
209
238
|
end
|
|
210
239
|
|
|
240
|
+
def create_state_bucket!
|
|
241
|
+
setup = Belt::CLI::SetupCommand.new(['--bucket', @resolved_bucket], quiet: true)
|
|
242
|
+
setup.run_state_setup
|
|
243
|
+
@resolved_bucket = setup.bucket_name
|
|
244
|
+
@state_setup_succeeded = true
|
|
245
|
+
puts " ✓ state bucket #{@resolved_bucket}"
|
|
246
|
+
rescue SystemExit
|
|
247
|
+
puts ' ⚠ state bucket setup failed — run `belt setup state` to retry'
|
|
248
|
+
@state_setup_succeeded = false
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def warn_missing_aws_for_state
|
|
252
|
+
if @aws_error&.include?('ForbiddenException') || @aws_error&.include?('AccessDenied')
|
|
253
|
+
puts ' ⚠ AWS credentials found but access denied — check profile/role'
|
|
254
|
+
else
|
|
255
|
+
puts ' ⚠ AWS credentials not detected — skipped state bucket'
|
|
256
|
+
end
|
|
257
|
+
puts ' run `belt doctor`, then `belt setup state`'
|
|
258
|
+
end
|
|
259
|
+
|
|
211
260
|
def aws_configured?
|
|
212
261
|
output, status = Open3.capture2e('aws', 'sts', 'get-caller-identity')
|
|
213
262
|
if status.success?
|
|
@@ -226,45 +275,64 @@ module Belt
|
|
|
226
275
|
|
|
227
276
|
def create_dir(dir)
|
|
228
277
|
FileUtils.mkdir_p(dir)
|
|
229
|
-
puts " create #{dir}/"
|
|
278
|
+
puts " create #{dir}/" if @verbose
|
|
230
279
|
end
|
|
231
280
|
|
|
232
281
|
def create_file(template_name, dest_path)
|
|
233
282
|
template_path = File.join(TEMPLATE_DIR, template_name)
|
|
234
283
|
content = ERB.new(File.read(template_path), trim_mode: '-').result(binding)
|
|
235
284
|
File.write(dest_path, content)
|
|
236
|
-
puts " create #{dest_path}"
|
|
285
|
+
puts " create #{dest_path}" if @verbose
|
|
237
286
|
end
|
|
238
287
|
|
|
239
288
|
def init_git
|
|
240
289
|
Dir.chdir(@app_name) do
|
|
241
290
|
system('git', 'init', '--quiet')
|
|
242
291
|
end
|
|
243
|
-
|
|
292
|
+
if @verbose
|
|
293
|
+
puts " init #{@app_name}/.git/"
|
|
294
|
+
else
|
|
295
|
+
puts ' init git'
|
|
296
|
+
end
|
|
244
297
|
end
|
|
245
298
|
|
|
246
299
|
def run_bundle_install
|
|
247
300
|
Dir.chdir(@app_name) do
|
|
248
|
-
puts "\n Running bundle install..."
|
|
301
|
+
puts "\n Running bundle install..." if @verbose
|
|
302
|
+
# Always --quiet: bundle's own progress is noise either way
|
|
249
303
|
success = system('bundle', 'install', '--quiet')
|
|
250
304
|
if success
|
|
251
|
-
puts ' ✓
|
|
305
|
+
puts ' ✓ bundle install'
|
|
252
306
|
else
|
|
253
|
-
puts ' ⚠
|
|
307
|
+
puts ' ⚠ bundle install failed — run it manually after resolving issues'
|
|
254
308
|
end
|
|
255
309
|
end
|
|
256
310
|
end
|
|
257
311
|
|
|
258
312
|
def generate_frontend
|
|
313
|
+
frontend_cmd = nil
|
|
259
314
|
Dir.chdir(@app_name) do
|
|
260
|
-
Belt::CLI::FrontendCommand.new(
|
|
315
|
+
frontend_cmd = Belt::CLI::FrontendCommand.new(
|
|
316
|
+
@frontend,
|
|
317
|
+
quiet: !@verbose,
|
|
318
|
+
announce: false
|
|
319
|
+
)
|
|
320
|
+
frontend_cmd.generate
|
|
321
|
+
end
|
|
322
|
+
return if @verbose
|
|
323
|
+
|
|
324
|
+
puts " create frontend (#{@frontend})"
|
|
325
|
+
if frontend_cmd.npm_ok?
|
|
326
|
+
puts ' ✓ npm dependencies'
|
|
327
|
+
else
|
|
328
|
+
puts ' ⚠ npm install failed — run `cd frontend && npm install`'
|
|
261
329
|
end
|
|
262
330
|
end
|
|
263
331
|
|
|
264
332
|
def print_next_steps
|
|
265
333
|
puts "\nNext steps:"
|
|
266
334
|
unless @state_setup_succeeded
|
|
267
|
-
puts ' #
|
|
335
|
+
puts ' belt doctor # Verify AWS CLI, Terraform & credentials'
|
|
268
336
|
puts ' belt setup state # Create the S3 state bucket'
|
|
269
337
|
end
|
|
270
338
|
puts ' belt deploy # Deploy to AWS'
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'open3'
|
|
5
|
+
require 'rubygems/package'
|
|
6
|
+
|
|
7
|
+
module Belt
|
|
8
|
+
module CLI
|
|
9
|
+
# Turns Gemfile `path:` gems into real `.gem` files in vendor/cache and
|
|
10
|
+
# rewrites the Gemfile/lock so Docker `bundle install` produces a normal
|
|
11
|
+
# gem install (with specifications/). Required for Lambda bare `require`
|
|
12
|
+
# without bundler/setup.
|
|
13
|
+
#
|
|
14
|
+
# Only mutates files under +build_dir+ — never the app's real Gemfile.
|
|
15
|
+
class PathGemMaterializer
|
|
16
|
+
PathSource = Struct.new(:remote, :gems, keyword_init: true)
|
|
17
|
+
PathGem = Struct.new(:name, :version, keyword_init: true)
|
|
18
|
+
|
|
19
|
+
def self.materialize!(build_dir, project_root:)
|
|
20
|
+
new(build_dir, project_root: project_root).materialize!
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(build_dir, project_root:)
|
|
24
|
+
@build_dir = build_dir
|
|
25
|
+
@project_root = project_root
|
|
26
|
+
@gemfile = File.join(build_dir, 'Gemfile')
|
|
27
|
+
@lockfile = File.join(build_dir, 'Gemfile.lock')
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @return [Array<String>] names of gems materialized (empty if none)
|
|
31
|
+
def materialize!
|
|
32
|
+
return [] unless File.exist?(@gemfile) && File.exist?(@lockfile)
|
|
33
|
+
|
|
34
|
+
sources = parse_path_sources(File.read(@lockfile))
|
|
35
|
+
return [] if sources.empty?
|
|
36
|
+
|
|
37
|
+
cache_dir = File.join(@build_dir, 'vendor', 'cache')
|
|
38
|
+
FileUtils.mkdir_p(cache_dir)
|
|
39
|
+
|
|
40
|
+
materialized = []
|
|
41
|
+
sources.each do |source|
|
|
42
|
+
source_path = resolve_remote(source.remote)
|
|
43
|
+
abort "✗ path gem source missing: #{source.remote} (resolved #{source_path})" unless Dir.exist?(source_path)
|
|
44
|
+
|
|
45
|
+
source.gems.each do |gem|
|
|
46
|
+
gem_file = build_gem(source_path, gem)
|
|
47
|
+
dest = File.join(cache_dir, File.basename(gem_file))
|
|
48
|
+
FileUtils.cp(gem_file, dest)
|
|
49
|
+
FileUtils.rm_f(gem_file)
|
|
50
|
+
rewrite_gemfile_path_to_version!(gem.name, gem.version)
|
|
51
|
+
materialized << gem.name
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
return [] if materialized.empty?
|
|
56
|
+
|
|
57
|
+
relock!(materialized)
|
|
58
|
+
materialized
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def parse_path_sources(lockfile_content)
|
|
64
|
+
sources = []
|
|
65
|
+
lockfile_content.scan(/^PATH\n remote: (.+)\n specs:\n((?: .+\n)*)/) do |remote, specs_block|
|
|
66
|
+
gems = []
|
|
67
|
+
specs_block.each_line do |line|
|
|
68
|
+
next unless line.match?(/^ \S/)
|
|
69
|
+
next if line.start_with?(' ') # dependency lines are deeper
|
|
70
|
+
|
|
71
|
+
match = line.match(/^ (\S+)\s+\(([^)]+)\)/)
|
|
72
|
+
gems << PathGem.new(name: match[1], version: match[2]) if match
|
|
73
|
+
end
|
|
74
|
+
sources << PathSource.new(remote: remote.strip, gems: gems) if gems.any?
|
|
75
|
+
end
|
|
76
|
+
sources
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def resolve_remote(remote)
|
|
80
|
+
return remote if remote.start_with?('/')
|
|
81
|
+
|
|
82
|
+
File.expand_path(remote, @project_root)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def build_gem(source_path, gem)
|
|
86
|
+
gemspec = find_gemspec(source_path, gem.name)
|
|
87
|
+
abort "✗ No gemspec for path gem #{gem.name} under #{source_path}" unless gemspec
|
|
88
|
+
|
|
89
|
+
Dir.chdir(source_path) do
|
|
90
|
+
spec = Gem::Specification.load(File.basename(gemspec))
|
|
91
|
+
abort "✗ Failed to load #{gemspec}" unless spec
|
|
92
|
+
|
|
93
|
+
if spec.version.to_s != gem.version
|
|
94
|
+
abort "✗ path gem #{gem.name} version mismatch: gemspec #{spec.version}, lock #{gem.version}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Gem::Package.build writes the .gem into cwd
|
|
98
|
+
built = Gem::Package.build(spec)
|
|
99
|
+
File.expand_path(built)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def find_gemspec(source_path, gem_name)
|
|
104
|
+
preferred = File.join(source_path, "#{gem_name}.gemspec")
|
|
105
|
+
return preferred if File.exist?(preferred)
|
|
106
|
+
|
|
107
|
+
Dir.glob(File.join(source_path, '*.gemspec')).first
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def rewrite_gemfile_path_to_version!(name, version)
|
|
111
|
+
content = File.read(@gemfile)
|
|
112
|
+
new_content = content.lines.map { |line| convert_path_line(line, name, version) }.join
|
|
113
|
+
abort "✗ Could not rewrite path: for gem #{name.inspect} in build Gemfile" if new_content == content
|
|
114
|
+
File.write(@gemfile, new_content)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Converts `gem 'name', path: '...'` (and variants with other kwargs) into
|
|
118
|
+
# a version-pinned line. Leaves non-matching lines alone.
|
|
119
|
+
def convert_path_line(line, name, version)
|
|
120
|
+
return line unless line.match?(/gem\s+(['"])#{Regexp.escape(name)}\1/)
|
|
121
|
+
return line unless line.match?(/\bpath:\s*/)
|
|
122
|
+
|
|
123
|
+
quote = line[/gem\s+(['"])/, 1] || "'"
|
|
124
|
+
cleaned = line.sub(/,?\s*path:\s*['"][^'"]+['"]/, '')
|
|
125
|
+
cleaned.sub(/gem\s+(['"])#{Regexp.escape(name)}\1/) do
|
|
126
|
+
"gem #{quote}#{name}#{quote}, #{quote}#{version}#{quote}"
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def relock!(gem_names)
|
|
131
|
+
Dir.chdir(@build_dir) do
|
|
132
|
+
# vendor/cache has the built .gem — Bundler resolves the unpublished version
|
|
133
|
+
output, status = Open3.capture2e('bundle', 'lock', '--update', *gem_names)
|
|
134
|
+
return if status.success?
|
|
135
|
+
|
|
136
|
+
abort "✗ Failed to re-lock after materializing path gems (#{gem_names.join(', ')}):\n#{output}"
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -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
|