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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +119 -0
  3. data/README.md +217 -8
  4. data/lib/belt/action_router.rb +33 -5
  5. data/lib/belt/assets/welcome.css +119 -47
  6. data/lib/belt/cli/bucket_security.rb +10 -4
  7. data/lib/belt/cli/deploy_command.rb +48 -0
  8. data/lib/belt/cli/destroy_command.rb +119 -10
  9. data/lib/belt/cli/doctor_command.rb +208 -0
  10. data/lib/belt/cli/environment_command.rb +38 -6
  11. data/lib/belt/cli/frontend_command.rb +25 -12
  12. data/lib/belt/cli/frontend_setup_command.rb +41 -0
  13. data/lib/belt/cli/new_command.rb +106 -38
  14. data/lib/belt/cli/path_gem_materializer.rb +141 -0
  15. data/lib/belt/cli/plugin_command.rb +221 -0
  16. data/lib/belt/cli/setup_command.rb +35 -10
  17. data/lib/belt/cli.rb +8 -0
  18. data/lib/belt/configuration.rb +30 -0
  19. data/lib/belt/controllers/welcome_controller.rb +69 -2
  20. data/lib/belt/helpers/cors_origin.rb +19 -2
  21. data/lib/belt/helpers/response.rb +48 -6
  22. data/lib/belt/http_status.rb +89 -0
  23. data/lib/belt/lambda_handler.rb +14 -5
  24. data/lib/belt/rendering.rb +1 -0
  25. data/lib/belt/version.rb +1 -1
  26. data/lib/belt/views/welcome/show.html.erb +31 -31
  27. data/lib/belt.rb +16 -0
  28. data/lib/belt_controller/base.rb +24 -1
  29. data/lib/belt_controller/implicit_response.rb +104 -0
  30. data/lib/templates/environment/backend.tf.erb +1 -1
  31. data/lib/templates/frontend/react/src/index.css +4 -4
  32. data/lib/templates/frontend/react/src/lib/apiClient.js.erb +20 -3
  33. data/lib/templates/frontend/react/src/pages/Home.jsx.erb +283 -4
  34. data/lib/templates/module/main.tf.erb +13 -3
  35. data/lib/templates/new_app/config/routes.tf.rb.erb +2 -1
  36. data/lib/templates/plugin/AGENTS.md.erb +135 -0
  37. data/lib/templates/plugin/CHANGELOG.md.erb +5 -0
  38. data/lib/templates/plugin/Gemfile.erb +11 -0
  39. data/lib/templates/plugin/LICENSE.erb +21 -0
  40. data/lib/templates/plugin/README.md.erb +63 -0
  41. data/lib/templates/plugin/Rakefile.erb +7 -0
  42. data/lib/templates/plugin/gemspec.erb +26 -0
  43. data/lib/templates/plugin/gitignore.erb +11 -0
  44. data/lib/templates/plugin/lib/belt/generators/generator.rb.erb +122 -0
  45. data/lib/templates/plugin/lib/belt/module/configuration.rb.erb +15 -0
  46. data/lib/templates/plugin/lib/belt/module/version.rb.erb +7 -0
  47. data/lib/templates/plugin/lib/belt/module.rb.erb +27 -0
  48. data/lib/templates/plugin/lib/entry.rb.erb +3 -0
  49. data/lib/templates/plugin/rspec.erb +3 -0
  50. data/lib/templates/plugin/spec/configuration_spec.rb.erb +17 -0
  51. data/lib/templates/plugin/spec/spec_helper.rb.erb +19 -0
  52. 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
@@ -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(app_name, frontend: frontend, bucket: bucket, environments: environments, domain: domain).generate
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
- def initialize(app_name, frontend: nil, bucket: nil, environments: nil, domain: nil)
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(env_name, quiet: true, domain: @domain).generate
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) do
183
- # Shared bucket: one per AWS account, all belt apps share it
184
- @resolved_bucket = @bucket || 'belt-terraform-state'
185
-
186
- # Attempt to actually create the bucket if credentials are available
187
- if aws_configured?
188
- puts "\n Setting up Terraform state bucket..."
189
- begin
190
- Belt::CLI::SetupCommand.new(['--bucket', @resolved_bucket]).run_state_setup
191
- @state_setup_succeeded = true
192
- rescue SystemExit
193
- puts ' ⚠ State bucket setup encountered an issue — run `belt setup state` to retry.'
194
- @state_setup_succeeded = false
195
- end
196
- else
197
- puts "\n State bucket: #{@resolved_bucket}"
198
- puts " State keys: #{s3_safe_name(@app_name)}/<env>/terraform.tfstate"
199
- if @aws_error&.include?('ForbiddenException') || @aws_error&.include?('AccessDenied')
200
- puts ' ⚠ AWS credentials found but access denied — check your profile/role configuration.'
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
- puts " init #{@app_name}/.git/"
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 ' ✓ Bundle installed'
305
+ puts ' ✓ bundle install'
252
306
  else
253
- puts ' ⚠ bundle install failed — run it manually after resolving issues.'
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(@frontend).generate
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 ' # Configure AWS credentials (aws sso login / AWS_PROFILE)'
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