belt 0.1.4 → 0.1.6

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.
@@ -26,9 +26,10 @@ module Belt
26
26
  new(env_name).generate
27
27
  end
28
28
 
29
- def initialize(env_name)
29
+ def initialize(env_name, quiet: false)
30
30
  @env_name = env_name.downcase.gsub(/[^a-z0-9_-]/, '')
31
31
  @app_name = detect_app_name
32
+ @quiet = quiet
32
33
  end
33
34
 
34
35
  def generate
@@ -49,11 +50,14 @@ module Belt
49
50
  end
50
51
 
51
52
  puts "\n✓ Environment '#{@env_name}' created!"
52
- puts "\nNext steps:"
53
- puts " cd #{dest_dir}"
54
- puts ' terraform init'
55
- puts ' terraform plan'
56
- puts ' terraform apply'
53
+
54
+ unless @quiet
55
+ puts "\nNext steps:"
56
+ puts " cd #{dest_dir}"
57
+ puts ' terraform init'
58
+ puts ' terraform plan'
59
+ puts ' terraform apply'
60
+ end
57
61
  end
58
62
 
59
63
  private
@@ -63,7 +67,8 @@ module Belt
63
67
  'main.tf.erb' => 'main.tf',
64
68
  'backend.tf.erb' => 'backend.tf',
65
69
  'variables.tf.erb' => 'variables.tf',
66
- 'terraform.tfvars.erb' => 'terraform.tfvars'
70
+ 'terraform.tfvars.erb' => 'terraform.tfvars',
71
+ 'outputs.tf.erb' => 'outputs.tf'
67
72
  }
68
73
  end
69
74
 
@@ -15,19 +15,79 @@ module Belt
15
15
 
16
16
  include AppDetection
17
17
 
18
+ FIELD_TYPES = %w[string text integer float boolean date datetime].freeze
19
+
20
+ GENERATOR_HELP = {
21
+ 'resource' => {
22
+ description: 'Generate a model, controller, routes, and schema for a REST resource.',
23
+ usage: 'belt generate resource <name> [field:type ...] [options]',
24
+ options: [
25
+ ['--skip-views', 'Skip generating frontend view pages']
26
+ ],
27
+ examples: [
28
+ ['belt g resource post title:string body:text', 'Post with title and body fields'],
29
+ ['belt g resource comment author:string body:text status:string', 'Comment with multiple fields'],
30
+ ['belt g resource task --skip-views', 'Task resource without frontend pages']
31
+ ],
32
+ notes: <<~NOTES
33
+ Creates:
34
+ lambda/models/<name>.rb Model with validations and fields
35
+ lambda/controllers/<app>/<names>_controller.rb RESTful controller (index, show, create, update, destroy)
36
+ infrastructure/routes.tf.rb Route entry added
37
+ infrastructure/schema.tf.rb DynamoDB table schema added
38
+ lambda/lib/routes/<app>_routes.rb Route manifest updated
39
+ NOTES
40
+ },
41
+ 'model' => {
42
+ description: 'Generate an ActiveItem model with validations and DynamoDB field definitions.',
43
+ usage: 'belt generate model <name> [field:type ...]',
44
+ options: [],
45
+ examples: [
46
+ ['belt g model user email:string name:string', 'User model with email and name'],
47
+ ['belt g model event title:string starts_at:datetime', 'Event model with datetime field']
48
+ ],
49
+ notes: <<~NOTES
50
+ Creates:
51
+ lambda/models/<name>.rb Model class inheriting from ActiveItem::Base
52
+ NOTES
53
+ },
54
+ 'controller' => {
55
+ description: 'Generate a controller with standard REST actions.',
56
+ usage: 'belt generate controller <name>',
57
+ options: [],
58
+ examples: [
59
+ ['belt g controller posts', 'Posts controller with CRUD actions'],
60
+ ['belt g controller admin/users', 'Namespaced controller']
61
+ ],
62
+ notes: <<~NOTES
63
+ Creates:
64
+ lambda/controllers/<app>/<name>_controller.rb Controller class
65
+ NOTES
66
+ }
67
+ }.freeze
68
+
18
69
  def self.run(args)
19
70
  generator = args.shift
20
71
 
21
- if generator.nil? || !GENERATORS.include?(generator)
22
- puts "Usage: belt generate <#{GENERATORS.join('|')}> <name> [field:type ...]"
23
- puts "\nExamples:"
24
- puts ' belt generate resource post title:string content:text status:string'
25
- puts ' belt generate model comment body:text author:string'
26
- puts ' belt generate controller comments'
27
- puts ' belt generate environment dev01'
72
+ # Top-level help: belt generate --help or belt generate (no args)
73
+ if generator.nil? || generator =~ /\A-/
74
+ print_generate_help
75
+ exit 0
76
+ end
77
+
78
+ unless GENERATORS.include?(generator)
79
+ puts "Unknown generator: '#{generator}'"
80
+ puts "Available generators: #{GENERATORS.join(', ')}"
81
+ puts "\nRun 'belt generate --help' for usage information."
28
82
  exit 1
29
83
  end
30
84
 
85
+ # Per-generator help: belt g resource --help
86
+ if args.include?('--help') || args.include?('-h')
87
+ print_generator_help(generator)
88
+ exit 0
89
+ end
90
+
31
91
  return Belt::CLI::EnvironmentCommand.run(args) if generator == 'environment'
32
92
 
33
93
  return Belt::CLI::FrontendCommand.run(args) if generator == 'frontend'
@@ -36,10 +96,12 @@ module Belt
36
96
 
37
97
  name = args.shift
38
98
  if name.nil? || name.empty?
39
- puts "Usage: belt generate #{generator} <name> [field:type ...]"
40
- exit 1
99
+ print_generator_help(generator)
100
+ exit 0
41
101
  end
42
102
 
103
+ validate_resource_name!(name, generator)
104
+
43
105
  skip_views = args.delete('--skip-views')
44
106
  fields = args.map { |arg| parse_field(arg) }
45
107
  new(generator, name, fields, skip_views: skip_views).generate
@@ -50,12 +112,105 @@ module Belt
50
112
  { name: name, type: type || 'string' }
51
113
  end
52
114
 
115
+ RESERVED_NAMES = %w[
116
+ help new edit create update destroy index show delete remove
117
+ application base controller model resource generate
118
+ belt setup environment frontend views routes
119
+ ].freeze
120
+
121
+ def self.validate_resource_name!(name, generator)
122
+ errors = []
123
+
124
+ errors << 'must start with a letter' unless name.match?(/\A[a-zA-Z]/)
125
+ errors << 'must be at least 2 characters' if name.length < 2
126
+ errors << 'must only contain letters, numbers, and underscores' unless name.match?(/\A[a-zA-Z][a-zA-Z0-9_]*\z/)
127
+ errors << 'must not start or end with an underscore' if name.match?(/\A_|_\z/)
128
+ errors << 'must not contain consecutive underscores' if name.include?('__')
129
+ errors << "is a reserved name" if RESERVED_NAMES.include?(name.downcase.chomp('s'))
130
+ errors << "is a reserved name" if RESERVED_NAMES.include?(name.downcase)
131
+
132
+ return if errors.empty?
133
+
134
+ puts "\n✗ Invalid #{generator} name '#{name}':"
135
+ errors.uniq.each { |e| puts " - #{e}" }
136
+ puts "\nName must start with a letter, be at least 2 characters, and contain only letters, numbers, and single underscores."
137
+ exit 1
138
+ end
139
+
140
+ def self.print_generate_help
141
+ puts <<~HELP
142
+ Usage: belt generate <generator> <name> [field:type ...] [options]
143
+ belt g <generator> <name> [field:type ...] [options]
144
+
145
+ Generators:
146
+ resource Generate model, controller, routes, and schema (full REST resource)
147
+ model Generate an ActiveItem model
148
+ controller Generate a controller
149
+ environment Create a new deployment environment
150
+ frontend Scaffold a frontend app (react, vue, svelte)
151
+ views Generate React pages for a resource
152
+
153
+ Field Types:
154
+ #{FIELD_TYPES.join(', ')}
155
+
156
+ Examples:
157
+ belt g resource post title:string body:text status:string
158
+ belt g model user email:string name:string
159
+ belt g controller comments
160
+ belt g environment staging
161
+ belt g frontend react
162
+ belt g views post title:string body:text
163
+
164
+ Run 'belt generate <generator> --help' for detailed help on a specific generator.
165
+ HELP
166
+ end
167
+
168
+ def self.print_generator_help(generator)
169
+ info = GENERATOR_HELP[generator]
170
+
171
+ unless info
172
+ puts "Usage: belt generate #{generator} <name>"
173
+ puts "\nRun 'belt generate #{generator} --help' is not yet documented."
174
+ puts "Try 'belt generate --help' for the full list of generators."
175
+ return
176
+ end
177
+
178
+ puts info[:description]
179
+ puts
180
+ puts "Usage: #{info[:usage]}"
181
+
182
+ if info[:options].any?
183
+ puts "\nOptions:"
184
+ info[:options].each do |flag, desc|
185
+ puts " %-20s %s" % [flag, desc]
186
+ end
187
+ end
188
+
189
+ puts "\nField Types:"
190
+ puts " #{FIELD_TYPES.join(', ')}"
191
+
192
+ puts "\nExamples:"
193
+ info[:examples].each do |cmd, desc|
194
+ puts " #{cmd}"
195
+ puts " #{desc}" if desc
196
+ end
197
+
198
+ puts "\n#{info[:notes]}" if info[:notes]
199
+
200
+ puts "Naming Rules:"
201
+ puts " - Must start with a letter"
202
+ puts " - At least 2 characters"
203
+ puts " - Letters, numbers, and single underscores only"
204
+ puts " - No leading/trailing/consecutive underscores"
205
+ puts " - Cannot use reserved names (help, new, create, destroy, etc.)"
206
+ end
207
+
53
208
  def initialize(generator, name, fields, skip_views: false)
54
209
  @generator = generator
55
210
  @name = name.downcase.gsub(/[^a-z0-9_]/, '_')
56
211
  @fields = fields
57
212
  @skip_views = skip_views
58
- @app_name = detect_app_name
213
+ @app_name = detect_namespace
59
214
  @module_name = @app_name.split(/[-_]/).map(&:capitalize).join
60
215
  @resource_name = @name.end_with?('s') ? @name : "#{@name}s"
61
216
  @singular_name = @name.end_with?('s') ? @name.chomp('s') : @name
@@ -2,40 +2,76 @@
2
2
 
3
3
  require 'fileutils'
4
4
  require 'erb'
5
+ require 'json'
6
+ require 'open3'
7
+ require_relative 'setup_command'
5
8
 
6
9
  module Belt
7
10
  module CLI
8
11
  class NewCommand
9
12
  TEMPLATE_DIR = File.expand_path('../../templates/new_app', __dir__)
13
+ DEFAULT_ENVIRONMENTS = %w[dev prod].freeze
10
14
 
11
15
  def self.run(args)
12
16
  app_name = nil
13
17
  frontend = nil
18
+ bucket = nil
19
+ environments = nil
14
20
 
15
- args.each do |arg|
16
- if arg.start_with?('--frontend')
17
- frontend = if arg.include?('=')
18
- arg.split('=', 2).last
19
- else
20
- args[args.index(arg) + 1]
21
- end
22
- elsif !arg.start_with?('-')
23
- app_name ||= arg
21
+ i = 0
22
+ while i < args.length
23
+ arg = args[i]
24
+ case arg
25
+ when '--frontend'
26
+ if arg.include?('=')
27
+ frontend = arg.split('=', 2).last
28
+ else
29
+ i += 1
30
+ frontend = args[i]
31
+ end
32
+ when /^--frontend=/
33
+ frontend = arg.split('=', 2).last
34
+ when '--bucket', '--state-bucket'
35
+ i += 1
36
+ bucket = args[i]
37
+ when /^--bucket=/
38
+ bucket = arg.split('=', 2).last
39
+ when /^--state-bucket=/
40
+ bucket = arg.split('=', 2).last
41
+ when '--environments'
42
+ i += 1
43
+ environments = args[i]
44
+ when /^--environments=/
45
+ environments = arg.split('=', 2).last
46
+ else
47
+ app_name ||= arg unless arg.start_with?('-')
24
48
  end
49
+ i += 1
25
50
  end
26
51
 
27
52
  if app_name.nil? || app_name.empty?
28
- puts 'Usage: belt new <app_name> [--frontend react|vue|svelte]'
53
+ puts 'Usage: belt new <app_name> [options]'
54
+ puts ''
55
+ puts 'Options:'
56
+ puts ' --frontend react|vue|svelte Set up frontend framework'
57
+ puts ' --bucket BUCKET_NAME S3 bucket for Terraform state'
58
+ puts ' --state-bucket BUCKET_NAME Alias for --bucket'
59
+ puts ' --environments dev,prod Comma-separated environments (default: dev,prod)'
60
+ puts ' Use "none" to skip environment setup'
29
61
  exit 1
30
62
  end
31
63
 
32
- new(app_name, frontend: frontend).generate
64
+ new(app_name, frontend: frontend, bucket: bucket, environments: environments).generate
33
65
  end
34
66
 
35
- def initialize(app_name, frontend: nil)
67
+ def initialize(app_name, frontend: nil, bucket: nil, environments: nil)
36
68
  @app_name = app_name.gsub(/[^a-z0-9_-]/i, '_').downcase
37
69
  @module_name = @app_name.split(/[-_]/).map(&:capitalize).join
38
70
  @frontend = frontend
71
+ @bucket = bucket
72
+ @environments = parse_environments(environments)
73
+ @resolved_bucket = nil
74
+ @state_setup_succeeded = false
39
75
  end
40
76
 
41
77
  def generate
@@ -47,19 +83,23 @@ module Belt
47
83
  puts "Creating new Belt application: #{@app_name}"
48
84
  create_structure
49
85
  generate_frontend if @frontend
86
+ generate_environments
50
87
  init_git
88
+ run_bundle_install
89
+ setup_state
51
90
  puts "\n✓ #{@app_name} created successfully!"
52
- puts "\nNext steps:"
53
- puts " cd #{@app_name}"
54
- puts ' bundle install'
55
- puts ' cd frontend && npm install && npm run dev' if @frontend
56
- puts ' # Define your models in infrastructure/schema.tf.rb'
57
- puts ' # Define your routes in infrastructure/routes.tf.rb'
58
- puts " # Add controllers in lambda/controllers/#{@app_name}/"
91
+ print_next_steps
59
92
  end
60
93
 
61
94
  private
62
95
 
96
+ def parse_environments(env_string)
97
+ return DEFAULT_ENVIRONMENTS if env_string.nil? || env_string.empty?
98
+ return [] if env_string.downcase == 'none'
99
+
100
+ env_string.split(',').map(&:strip).reject(&:empty?)
101
+ end
102
+
63
103
  def create_structure
64
104
  directories.each { |dir| create_dir(dir) }
65
105
  files.each { |src, dest| create_file(src, dest) }
@@ -67,7 +107,7 @@ module Belt
67
107
 
68
108
  def directories
69
109
  %W[
70
- #{@app_name}/lambda/controllers/#{@app_name}
110
+ #{@app_name}/lambda/controllers/api
71
111
  #{@app_name}/lambda/models
72
112
  #{@app_name}/lambda/models/concerns
73
113
  #{@app_name}/lambda/lib/routes
@@ -81,13 +121,13 @@ module Belt
81
121
  {
82
122
  'Gemfile.erb' => "#{@app_name}/Gemfile",
83
123
  'Rakefile.erb' => "#{@app_name}/Rakefile",
84
- 'lambda/api.rb.erb' => "#{@app_name}/lambda/#{@app_name}.rb",
124
+ 'lambda/api.rb.erb' => "#{@app_name}/lambda/api.rb",
85
125
  'lambda/config/environment.rb.erb' => "#{@app_name}/lambda/config/environment.rb",
86
126
  'lambda/models/application_record.rb.erb' => "#{@app_name}/lambda/models/application_record.rb",
87
127
  'lambda/models/concerns/timestampable.rb.erb' => "#{@app_name}/lambda/models/concerns/timestampable.rb",
88
128
  'lambda/controllers/application_controller.rb.erb' =>
89
- "#{@app_name}/lambda/controllers/#{@app_name}/application_controller.rb",
90
- 'lambda/lib/routes/routes.rb.erb' => "#{@app_name}/lambda/lib/routes/#{@app_name}_routes.rb",
129
+ "#{@app_name}/lambda/controllers/api/application_controller.rb",
130
+ 'lambda/lib/routes/routes.rb.erb' => "#{@app_name}/lambda/lib/routes/api_routes.rb",
91
131
  'infrastructure/routes.tf.rb.erb' => "#{@app_name}/infrastructure/routes.tf.rb",
92
132
  'infrastructure/schema.tf.rb.erb' => "#{@app_name}/infrastructure/schema.tf.rb",
93
133
  'README.md.erb' => "#{@app_name}/README.md",
@@ -96,6 +136,84 @@ module Belt
96
136
  }
97
137
  end
98
138
 
139
+ def generate_environments
140
+ return if @environments.empty?
141
+
142
+ Dir.chdir(@app_name) do
143
+ @environments.each do |env_name|
144
+ Belt::CLI::EnvironmentCommand.new(env_name, quiet: true).generate
145
+ end
146
+ end
147
+ end
148
+
149
+ def setup_state
150
+ return if @environments.empty?
151
+
152
+ Dir.chdir(@app_name) do
153
+ # Resolve bucket name — include account ID + region if AWS credentials are available
154
+ if @bucket
155
+ @resolved_bucket = @bucket
156
+ elsif aws_configured?
157
+ region = detect_region
158
+ @resolved_bucket = s3_safe_name("#{@app_name}-terraform-state-#{@aws_account_id}-#{region}")
159
+ else
160
+ @resolved_bucket = s3_safe_name("#{@app_name}-terraform-state")
161
+ end
162
+
163
+ # Update backend.tf files with the resolved bucket name
164
+ @environments.each do |env_name|
165
+ backend_file = "infrastructure/#{env_name}/backend.tf"
166
+ next unless File.exist?(backend_file)
167
+
168
+ content = File.read(backend_file)
169
+ updated = content.gsub(/bucket\s*=\s*"[^"]+"/, "bucket = \"#{@resolved_bucket}\"")
170
+ File.write(backend_file, updated) if updated != content
171
+ end
172
+
173
+ # Attempt to actually create the bucket if credentials are available
174
+ if @aws_account_id
175
+ puts "\n Setting up Terraform state bucket..."
176
+ begin
177
+ Belt::CLI::SetupCommand.new(["--bucket", @resolved_bucket]).run_state_setup
178
+ @state_setup_succeeded = true
179
+ rescue SystemExit
180
+ puts ' ⚠ State bucket setup encountered an issue — run `belt setup state` to retry.'
181
+ @state_setup_succeeded = false
182
+ end
183
+ else
184
+ puts "\n State bucket: #{@resolved_bucket}"
185
+ if @aws_error&.include?('ForbiddenException') || @aws_error&.include?('AccessDenied')
186
+ puts " ⚠ AWS credentials found but access denied — check your profile/role configuration."
187
+ puts " #{@aws_error}" if @aws_error
188
+ else
189
+ puts ' ⚠ AWS credentials not detected — skipping state bucket creation.'
190
+ end
191
+ puts ' Run `belt setup state` after configuring credentials (aws sso login / AWS_PROFILE).'
192
+ @state_setup_succeeded = false
193
+ end
194
+ end
195
+ end
196
+
197
+ def detect_region
198
+ Dir.glob('infrastructure/*/backend.tf').each do |f|
199
+ match = File.read(f).match(/region\s*=\s*"([^"]+)"/)
200
+ return match[1] if match
201
+ end
202
+ 'us-east-1'
203
+ end
204
+
205
+ def aws_configured?
206
+ output, status = Open3.capture2e('aws', 'sts', 'get-caller-identity')
207
+ if status.success?
208
+ data = JSON.parse(output) rescue {}
209
+ @aws_account_id = data['Account']
210
+ true
211
+ else
212
+ @aws_error = output.strip
213
+ false
214
+ end
215
+ end
216
+
99
217
  def create_dir(dir)
100
218
  FileUtils.mkdir_p(dir)
101
219
  puts " create #{dir}/"
@@ -111,17 +229,42 @@ module Belt
111
229
  def init_git
112
230
  Dir.chdir(@app_name) do
113
231
  system('git', 'init', '--quiet')
114
- system('git', 'add', '.')
115
- system('git', 'commit', '-m', 'Initial commit', '--quiet')
116
232
  end
117
233
  puts " init #{@app_name}/.git/"
118
234
  end
119
235
 
236
+ def run_bundle_install
237
+ Dir.chdir(@app_name) do
238
+ puts "\n Running bundle install..."
239
+ success = system('bundle', 'install', '--quiet')
240
+ if success
241
+ puts ' ✓ Bundle installed'
242
+ else
243
+ puts ' ⚠ bundle install failed — run it manually after resolving issues.'
244
+ end
245
+ end
246
+ end
247
+
120
248
  def generate_frontend
121
249
  Dir.chdir(@app_name) do
122
250
  Belt::CLI::FrontendCommand.new(@frontend).generate
123
251
  end
124
252
  end
253
+
254
+ def print_next_steps
255
+ puts "\nNext steps:"
256
+ puts " cd #{@app_name}"
257
+ unless @state_setup_succeeded
258
+ puts ' # Configure AWS credentials (aws sso login / AWS_PROFILE)'
259
+ puts ' belt setup state # Create the S3 state bucket'
260
+ end
261
+ puts ' belt deploy # Deploy to AWS'
262
+ puts ' belt server # Start local frontend server' if @frontend
263
+ end
264
+
265
+ def s3_safe_name(name)
266
+ name.to_s.downcase.tr('_', '-')
267
+ end
125
268
  end
126
269
  end
127
270
  end