belt 0.1.6 → 0.1.8
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/lib/belt/cli/app_detection.rb +6 -0
- data/lib/belt/cli/deploy_command.rb +15 -0
- data/lib/belt/cli/destroy_command.rb +320 -0
- data/lib/belt/cli/environment_command.rb +11 -0
- data/lib/belt/cli/frontend_command.rb +35 -3
- data/lib/belt/cli/frontend_setup_command.rb +35 -2
- data/lib/belt/cli/generate_command.rb +79 -38
- data/lib/belt/cli/new_command.rb +1 -3
- data/lib/belt/cli/routes_command/route_inference.rb +4 -0
- data/lib/belt/cli/routes_command.rb +1 -9
- data/lib/belt/cli/tables_command.rb +53 -15
- data/lib/belt/cli/views_command.rb +11 -8
- data/lib/belt/cli.rb +24 -3
- data/lib/belt/inflector.rb +58 -0
- data/lib/belt/lambda_handler.rb +11 -0
- data/lib/belt/route_dsl.rb +3 -11
- data/lib/belt/table_inference.rb +4 -18
- data/lib/belt/version.rb +1 -1
- data/lib/templates/environment/main.tf.erb +1 -1
- data/lib/templates/environment/outputs.tf.erb +5 -0
- data/lib/templates/frontend_infra/frontend.tf.erb +0 -4
- data/lib/templates/generate/controller.rb.erb +0 -1
- data/lib/templates/generate/model.rb.erb +0 -2
- data/lib/templates/new_app/lambda/config/environment.rb.erb +7 -1
- metadata +17 -2
- data/lib/templates/new_app/lambda/models/concerns/timestampable.rb.erb +0 -23
|
@@ -5,29 +5,31 @@ require 'erb'
|
|
|
5
5
|
require_relative 'app_detection'
|
|
6
6
|
require_relative 'environment_command'
|
|
7
7
|
require_relative 'frontend_command'
|
|
8
|
+
require_relative 'tables_command'
|
|
8
9
|
require_relative 'views_command'
|
|
10
|
+
require_relative '../inflector'
|
|
9
11
|
|
|
10
12
|
module Belt
|
|
11
13
|
module CLI
|
|
12
14
|
class GenerateCommand
|
|
13
15
|
TEMPLATE_DIR = File.expand_path('../../templates/generate', __dir__)
|
|
14
|
-
GENERATORS = %w[resource model controller environment frontend views].freeze
|
|
16
|
+
GENERATORS = %w[scaffold resource model controller environment frontend views].freeze
|
|
15
17
|
|
|
16
18
|
include AppDetection
|
|
17
19
|
|
|
18
20
|
FIELD_TYPES = %w[string text integer float boolean date datetime].freeze
|
|
19
21
|
|
|
20
22
|
GENERATOR_HELP = {
|
|
21
|
-
'
|
|
22
|
-
description: 'Generate a model, controller, routes, and
|
|
23
|
-
usage: 'belt generate
|
|
23
|
+
'scaffold' => {
|
|
24
|
+
description: 'Generate a model, controller, routes, schema, and views for a REST resource.',
|
|
25
|
+
usage: 'belt generate scaffold <name> [field:type ...] [options]',
|
|
24
26
|
options: [
|
|
25
27
|
['--skip-views', 'Skip generating frontend view pages']
|
|
26
28
|
],
|
|
27
29
|
examples: [
|
|
28
|
-
['belt g
|
|
29
|
-
['belt g
|
|
30
|
-
['belt g
|
|
30
|
+
['belt g scaffold post title body:text'],
|
|
31
|
+
['belt g scaffold comment author body:text status'],
|
|
32
|
+
['belt g scaffold task --skip-views']
|
|
31
33
|
],
|
|
32
34
|
notes: <<~NOTES
|
|
33
35
|
Creates:
|
|
@@ -36,6 +38,9 @@ module Belt
|
|
|
36
38
|
infrastructure/routes.tf.rb Route entry added
|
|
37
39
|
infrastructure/schema.tf.rb DynamoDB table schema added
|
|
38
40
|
lambda/lib/routes/<app>_routes.rb Route manifest updated
|
|
41
|
+
frontend/src/pages/<names>/ React pages (if frontend exists)
|
|
42
|
+
|
|
43
|
+
Alias: `belt g resource` works the same way.
|
|
39
44
|
NOTES
|
|
40
45
|
},
|
|
41
46
|
'model' => {
|
|
@@ -43,8 +48,8 @@ module Belt
|
|
|
43
48
|
usage: 'belt generate model <name> [field:type ...]',
|
|
44
49
|
options: [],
|
|
45
50
|
examples: [
|
|
46
|
-
['belt g model user email
|
|
47
|
-
['belt g model event title
|
|
51
|
+
['belt g model user email name'],
|
|
52
|
+
['belt g model event title starts_at:datetime']
|
|
48
53
|
],
|
|
49
54
|
notes: <<~NOTES
|
|
50
55
|
Creates:
|
|
@@ -56,8 +61,8 @@ module Belt
|
|
|
56
61
|
usage: 'belt generate controller <name>',
|
|
57
62
|
options: [],
|
|
58
63
|
examples: [
|
|
59
|
-
['belt g controller posts'
|
|
60
|
-
['belt g controller admin/users'
|
|
64
|
+
['belt g controller posts'],
|
|
65
|
+
['belt g controller admin/users']
|
|
61
66
|
],
|
|
62
67
|
notes: <<~NOTES
|
|
63
68
|
Creates:
|
|
@@ -77,12 +82,15 @@ module Belt
|
|
|
77
82
|
|
|
78
83
|
unless GENERATORS.include?(generator)
|
|
79
84
|
puts "Unknown generator: '#{generator}'"
|
|
80
|
-
puts "Available generators: #{GENERATORS.join(', ')}"
|
|
85
|
+
puts "Available generators: #{(GENERATORS - ['resource']).join(', ')}"
|
|
81
86
|
puts "\nRun 'belt generate --help' for usage information."
|
|
82
87
|
exit 1
|
|
83
88
|
end
|
|
84
89
|
|
|
85
|
-
#
|
|
90
|
+
# Normalize: resource is an alias for scaffold
|
|
91
|
+
generator = 'scaffold' if generator == 'resource'
|
|
92
|
+
|
|
93
|
+
# Per-generator help: belt g scaffold --help
|
|
86
94
|
if args.include?('--help') || args.include?('-h')
|
|
87
95
|
print_generator_help(generator)
|
|
88
96
|
exit 0
|
|
@@ -126,7 +134,7 @@ module Belt
|
|
|
126
134
|
errors << 'must only contain letters, numbers, and underscores' unless name.match?(/\A[a-zA-Z][a-zA-Z0-9_]*\z/)
|
|
127
135
|
errors << 'must not start or end with an underscore' if name.match?(/\A_|_\z/)
|
|
128
136
|
errors << 'must not contain consecutive underscores' if name.include?('__')
|
|
129
|
-
errors << "is a reserved name" if RESERVED_NAMES.include?(name.downcase
|
|
137
|
+
errors << "is a reserved name" if RESERVED_NAMES.include?(Belt::Inflector.singularize(name.downcase))
|
|
130
138
|
errors << "is a reserved name" if RESERVED_NAMES.include?(name.downcase)
|
|
131
139
|
|
|
132
140
|
return if errors.empty?
|
|
@@ -143,23 +151,27 @@ module Belt
|
|
|
143
151
|
belt g <generator> <name> [field:type ...] [options]
|
|
144
152
|
|
|
145
153
|
Generators:
|
|
146
|
-
|
|
154
|
+
scaffold Generate model, controller, routes, schema, and views (full REST resource)
|
|
147
155
|
model Generate an ActiveItem model
|
|
148
156
|
controller Generate a controller
|
|
149
157
|
environment Create a new deployment environment
|
|
150
158
|
frontend Scaffold a frontend app (react, vue, svelte)
|
|
151
159
|
views Generate React pages for a resource
|
|
152
160
|
|
|
161
|
+
Aliases:
|
|
162
|
+
resource Same as scaffold
|
|
163
|
+
|
|
153
164
|
Field Types:
|
|
154
165
|
#{FIELD_TYPES.join(', ')}
|
|
166
|
+
(defaults to string if omitted)
|
|
155
167
|
|
|
156
168
|
Examples:
|
|
157
|
-
belt g
|
|
158
|
-
belt g model user email
|
|
169
|
+
belt g scaffold post title body:text status
|
|
170
|
+
belt g model user email name
|
|
159
171
|
belt g controller comments
|
|
160
172
|
belt g environment staging
|
|
161
173
|
belt g frontend react
|
|
162
|
-
belt g views post title
|
|
174
|
+
belt g views post title body:text
|
|
163
175
|
|
|
164
176
|
Run 'belt generate <generator> --help' for detailed help on a specific generator.
|
|
165
177
|
HELP
|
|
@@ -188,21 +200,18 @@ module Belt
|
|
|
188
200
|
|
|
189
201
|
puts "\nField Types:"
|
|
190
202
|
puts " #{FIELD_TYPES.join(', ')}"
|
|
203
|
+
puts " (defaults to string if omitted)"
|
|
191
204
|
|
|
192
205
|
puts "\nExamples:"
|
|
193
206
|
info[:examples].each do |cmd, desc|
|
|
194
|
-
|
|
195
|
-
|
|
207
|
+
if desc
|
|
208
|
+
puts " #{cmd} — #{desc}"
|
|
209
|
+
else
|
|
210
|
+
puts " #{cmd}"
|
|
211
|
+
end
|
|
196
212
|
end
|
|
197
213
|
|
|
198
214
|
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
215
|
end
|
|
207
216
|
|
|
208
217
|
def initialize(generator, name, fields, skip_views: false)
|
|
@@ -212,15 +221,15 @@ module Belt
|
|
|
212
221
|
@skip_views = skip_views
|
|
213
222
|
@app_name = detect_namespace
|
|
214
223
|
@module_name = @app_name.split(/[-_]/).map(&:capitalize).join
|
|
215
|
-
@
|
|
216
|
-
@
|
|
217
|
-
@class_name = @singular_name
|
|
224
|
+
@singular_name = Belt::Inflector.singularize(@name)
|
|
225
|
+
@resource_name = Belt::Inflector.pluralize(@singular_name)
|
|
226
|
+
@class_name = Belt::Inflector.classify(@singular_name)
|
|
218
227
|
end
|
|
219
228
|
|
|
220
229
|
def generate
|
|
221
230
|
case @generator
|
|
222
|
-
when '
|
|
223
|
-
when 'model' then
|
|
231
|
+
when 'scaffold' then generate_resource
|
|
232
|
+
when 'model' then generate_model_standalone
|
|
224
233
|
when 'controller' then generate_controller
|
|
225
234
|
end
|
|
226
235
|
end
|
|
@@ -232,8 +241,9 @@ module Belt
|
|
|
232
241
|
generate_controller
|
|
233
242
|
inject_routes
|
|
234
243
|
inject_schema
|
|
244
|
+
sync_tables
|
|
235
245
|
generate_views_if_frontend
|
|
236
|
-
puts "\n✓
|
|
246
|
+
puts "\n✓ Scaffold '#{@singular_name}' generated!"
|
|
237
247
|
puts "\nFiles created/updated:"
|
|
238
248
|
puts " lambda/models/#{@singular_name}.rb"
|
|
239
249
|
puts " lambda/controllers/#{@app_name}/#{@resource_name}_controller.rb"
|
|
@@ -243,6 +253,12 @@ module Belt
|
|
|
243
253
|
puts " frontend/src/pages/#{@resource_name}/ (views)" if Dir.exist?('frontend/src')
|
|
244
254
|
end
|
|
245
255
|
|
|
256
|
+
def generate_model_standalone
|
|
257
|
+
generate_model
|
|
258
|
+
inject_schema
|
|
259
|
+
sync_tables
|
|
260
|
+
end
|
|
261
|
+
|
|
246
262
|
def generate_model
|
|
247
263
|
dest = "lambda/models/#{@singular_name}.rb"
|
|
248
264
|
write_template('model.rb.erb', dest)
|
|
@@ -261,14 +277,35 @@ module Belt
|
|
|
261
277
|
|
|
262
278
|
content = File.read(routes_file)
|
|
263
279
|
tables_arg = @fields.any? ? ", tables: [:#{@resource_name}]" : ''
|
|
280
|
+
resource_line = "resources :#{@resource_name}#{tables_arg}"
|
|
264
281
|
|
|
265
|
-
#
|
|
282
|
+
# Replace the commented placeholder if it exists
|
|
266
283
|
if content.include?('# resources :posts')
|
|
267
|
-
content.sub!('# resources :posts',
|
|
268
|
-
elsif content.match?(/namespace :\w+[^\n]*do\n(\s+#[^\n]*\n)*\s+end/)
|
|
269
|
-
content.sub!(/^(\s+)(end\s*\z)/m, "\\1 resources :#{@resource_name}#{tables_arg}\n\\1\\2")
|
|
284
|
+
content.sub!('# resources :posts', resource_line)
|
|
270
285
|
else
|
|
271
|
-
|
|
286
|
+
# Find the target namespace block and insert before its closing `end`
|
|
287
|
+
# Match: `namespace :<name> do` ... `end` (the first `end` at the same indent level)
|
|
288
|
+
namespace_pattern = /^(\s*)namespace :#{Regexp.escape(@app_name)}\b[^\n]*do\s*\n(.*?)^\1end/m
|
|
289
|
+
|
|
290
|
+
if content.match?(namespace_pattern)
|
|
291
|
+
content.sub!(namespace_pattern) do |match|
|
|
292
|
+
indent = $1
|
|
293
|
+
# Insert the resource line before the namespace's closing `end`
|
|
294
|
+
match.sub(/^(#{indent})end\z/m, "#{indent} #{resource_line}\n#{indent}end")
|
|
295
|
+
end
|
|
296
|
+
else
|
|
297
|
+
# Fallback: find any single namespace block and insert into it
|
|
298
|
+
single_ns_pattern = /^(\s*)namespace :\w+\b[^\n]*do\s*\n(.*?)^\1end/m
|
|
299
|
+
if content.match?(single_ns_pattern)
|
|
300
|
+
content.sub!(single_ns_pattern) do |match|
|
|
301
|
+
indent = $1
|
|
302
|
+
match.sub(/^(#{indent})end\z/m, "#{indent} #{resource_line}\n#{indent}end")
|
|
303
|
+
end
|
|
304
|
+
else
|
|
305
|
+
# No namespace block found at all — insert at top level before final end
|
|
306
|
+
content.sub!(/^(\s*end\s*)\z/m, " #{resource_line}\n\\1")
|
|
307
|
+
end
|
|
308
|
+
end
|
|
272
309
|
end
|
|
273
310
|
|
|
274
311
|
File.write(routes_file, content)
|
|
@@ -343,6 +380,10 @@ module Belt
|
|
|
343
380
|
puts " update #{schema_file}"
|
|
344
381
|
end
|
|
345
382
|
|
|
383
|
+
def sync_tables
|
|
384
|
+
Belt::CLI::TablesCommand.sync_all_environments
|
|
385
|
+
end
|
|
386
|
+
|
|
346
387
|
def write_template(template_name, dest_path)
|
|
347
388
|
template_path = File.join(TEMPLATE_DIR, template_name)
|
|
348
389
|
FileUtils.mkdir_p(File.dirname(dest_path))
|
data/lib/belt/cli/new_command.rb
CHANGED
|
@@ -82,8 +82,8 @@ module Belt
|
|
|
82
82
|
|
|
83
83
|
puts "Creating new Belt application: #{@app_name}"
|
|
84
84
|
create_structure
|
|
85
|
-
generate_frontend if @frontend
|
|
86
85
|
generate_environments
|
|
86
|
+
generate_frontend if @frontend
|
|
87
87
|
init_git
|
|
88
88
|
run_bundle_install
|
|
89
89
|
setup_state
|
|
@@ -109,7 +109,6 @@ module Belt
|
|
|
109
109
|
%W[
|
|
110
110
|
#{@app_name}/lambda/controllers/api
|
|
111
111
|
#{@app_name}/lambda/models
|
|
112
|
-
#{@app_name}/lambda/models/concerns
|
|
113
112
|
#{@app_name}/lambda/lib/routes
|
|
114
113
|
#{@app_name}/lambda/config
|
|
115
114
|
#{@app_name}/lambda/spec
|
|
@@ -124,7 +123,6 @@ module Belt
|
|
|
124
123
|
'lambda/api.rb.erb' => "#{@app_name}/lambda/api.rb",
|
|
125
124
|
'lambda/config/environment.rb.erb' => "#{@app_name}/lambda/config/environment.rb",
|
|
126
125
|
'lambda/models/application_record.rb.erb' => "#{@app_name}/lambda/models/application_record.rb",
|
|
127
|
-
'lambda/models/concerns/timestampable.rb.erb' => "#{@app_name}/lambda/models/concerns/timestampable.rb",
|
|
128
126
|
'lambda/controllers/application_controller.rb.erb' =>
|
|
129
127
|
"#{@app_name}/lambda/controllers/api/application_controller.rb",
|
|
130
128
|
'lambda/lib/routes/routes.rb.erb' => "#{@app_name}/lambda/lib/routes/api_routes.rb",
|
|
@@ -16,6 +16,10 @@ module Belt
|
|
|
16
16
|
|
|
17
17
|
return non_param.map { |s| s.gsub('-', '_') }.join('/') if route.resource? && nested_resource?(segments)
|
|
18
18
|
|
|
19
|
+
# For non-resource routes with a single segment (e.g., post '/signup' in :onboarding),
|
|
20
|
+
# the segment is the action name, not the controller. Use the gateway name as controller.
|
|
21
|
+
return gateway.name if !route.resource? && non_param.length == 1
|
|
22
|
+
|
|
19
23
|
non_param.first.gsub('-', '_')
|
|
20
24
|
end
|
|
21
25
|
|
|
@@ -292,15 +292,7 @@ module Belt
|
|
|
292
292
|
end
|
|
293
293
|
|
|
294
294
|
def singularize(word)
|
|
295
|
-
|
|
296
|
-
"#{word[0..-4]}y"
|
|
297
|
-
elsif word.end_with?('ses') || word.end_with?('xes') || word.end_with?('zes')
|
|
298
|
-
word[0..-3]
|
|
299
|
-
elsif word.end_with?('s') && !word.end_with?('ss')
|
|
300
|
-
word[0..-2]
|
|
301
|
-
else
|
|
302
|
-
word
|
|
303
|
-
end
|
|
295
|
+
Belt::Inflector.singularize(word)
|
|
304
296
|
end
|
|
305
297
|
end
|
|
306
298
|
end
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative 'app_detection'
|
|
4
4
|
require_relative 'env_resolver'
|
|
5
|
+
require_relative 'terraform_command'
|
|
6
|
+
require_relative '../inflector'
|
|
5
7
|
|
|
6
8
|
module Belt
|
|
7
9
|
module CLI
|
|
@@ -27,8 +29,22 @@ module Belt
|
|
|
27
29
|
new(env).run
|
|
28
30
|
end
|
|
29
31
|
|
|
30
|
-
|
|
32
|
+
# Automatically sync dynamodb.tf for all existing environments.
|
|
33
|
+
# Called by generators after updating schema.tf.rb.
|
|
34
|
+
def self.sync_all_environments
|
|
35
|
+
return unless File.exist?(SCHEMA_FILE)
|
|
36
|
+
|
|
37
|
+
environments = TerraformCommand.list_environments
|
|
38
|
+
return if environments.empty?
|
|
39
|
+
|
|
40
|
+
environments.each do |env|
|
|
41
|
+
new(env, quiet: true).run
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def initialize(env, quiet: false)
|
|
31
46
|
@env = env
|
|
47
|
+
@quiet = quiet
|
|
32
48
|
@app_name = detect_app_name
|
|
33
49
|
@env_dir = "infrastructure/#{@env}"
|
|
34
50
|
end
|
|
@@ -37,8 +53,8 @@ module Belt
|
|
|
37
53
|
validate!
|
|
38
54
|
models = parse_schema
|
|
39
55
|
if models.empty?
|
|
40
|
-
puts "No models found in #{SCHEMA_FILE}"
|
|
41
|
-
|
|
56
|
+
puts "No models found in #{SCHEMA_FILE}" unless @quiet
|
|
57
|
+
return
|
|
42
58
|
end
|
|
43
59
|
|
|
44
60
|
generate_dynamodb_tf(models)
|
|
@@ -47,32 +63,54 @@ module Belt
|
|
|
47
63
|
private
|
|
48
64
|
|
|
49
65
|
def validate!
|
|
50
|
-
|
|
66
|
+
unless File.exist?(SCHEMA_FILE)
|
|
67
|
+
abort "Error: #{SCHEMA_FILE} not found. Run `belt generate resource` first." unless @quiet
|
|
68
|
+
return
|
|
69
|
+
end
|
|
51
70
|
return if Dir.exist?(@env_dir)
|
|
52
71
|
|
|
53
|
-
|
|
54
|
-
|
|
72
|
+
unless @quiet
|
|
73
|
+
abort "Error: Environment '#{@env}' not found at #{@env_dir}/.\n" \
|
|
74
|
+
"Create it with: belt generate environment #{@env}"
|
|
75
|
+
end
|
|
55
76
|
end
|
|
56
77
|
|
|
57
78
|
def parse_schema
|
|
79
|
+
return [] unless File.exist?(SCHEMA_FILE)
|
|
80
|
+
|
|
58
81
|
parser = SchemaParser.new
|
|
59
82
|
schema_content = File.read(SCHEMA_FILE)
|
|
60
83
|
|
|
61
84
|
# Replace DSL wrapper with direct parser call
|
|
62
85
|
# Belt.application.schema.draw do ... end → parser.instance_eval do ... end
|
|
63
|
-
inner = schema_content.sub(/\A(?:Belt\.application)\.schema\.draw do\n?/, '').sub(/\
|
|
86
|
+
inner = schema_content.sub(/\A(?:Belt\.application)\.schema\.draw do\n?/, '').sub(/\n?end\s*\z/, '')
|
|
87
|
+
inner.strip!
|
|
88
|
+
|
|
89
|
+
return [] if inner.empty? || inner.match?(/\A\s*\z/m)
|
|
90
|
+
|
|
64
91
|
parser.instance_eval(inner, SCHEMA_FILE)
|
|
65
92
|
parser.models
|
|
66
93
|
end
|
|
67
94
|
|
|
68
95
|
def generate_dynamodb_tf(models)
|
|
69
96
|
dest = File.join(@env_dir, 'dynamodb.tf')
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
97
|
+
existing_content = File.exist?(dest) ? File.read(dest) : nil
|
|
98
|
+
new_content = render_dynamodb(models)
|
|
99
|
+
|
|
100
|
+
# Skip if content is unchanged
|
|
101
|
+
return if existing_content == new_content
|
|
102
|
+
|
|
103
|
+
File.write(dest, new_content)
|
|
104
|
+
|
|
105
|
+
if @quiet
|
|
106
|
+
verb = existing_content ? 'update' : 'create'
|
|
107
|
+
puts " #{verb} #{dest}"
|
|
108
|
+
else
|
|
109
|
+
puts " create #{dest}"
|
|
110
|
+
puts "\n✓ Generated DynamoDB tables for #{models.size} model(s):"
|
|
111
|
+
models.each { |m| puts " • #{table_name(m[:name])}" }
|
|
112
|
+
puts "\nRun `belt deploy` to create them."
|
|
113
|
+
end
|
|
76
114
|
end
|
|
77
115
|
|
|
78
116
|
def render_dynamodb(models)
|
|
@@ -84,7 +122,7 @@ module Belt
|
|
|
84
122
|
def render_table(model)
|
|
85
123
|
name = table_name(model[:name])
|
|
86
124
|
<<~HCL
|
|
87
|
-
resource "aws_dynamodb_table" "#{model[:name]}
|
|
125
|
+
resource "aws_dynamodb_table" "#{Belt::Inflector.pluralize(model[:name])}" {
|
|
88
126
|
name = "#{name}"
|
|
89
127
|
billing_mode = "PAY_PER_REQUEST"
|
|
90
128
|
hash_key = "id"
|
|
@@ -104,7 +142,7 @@ module Belt
|
|
|
104
142
|
end
|
|
105
143
|
|
|
106
144
|
def table_name(model_name)
|
|
107
|
-
"#{@app_name}-#{@env}-#{model_name}
|
|
145
|
+
"#{@app_name}-#{@env}-#{Belt::Inflector.pluralize(model_name)}"
|
|
108
146
|
end
|
|
109
147
|
|
|
110
148
|
# Minimal DSL parser for schema.tf.rb
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'fileutils'
|
|
4
4
|
require 'erb'
|
|
5
|
+
require_relative '../inflector'
|
|
5
6
|
|
|
6
7
|
module Belt
|
|
7
8
|
module CLI
|
|
@@ -35,7 +36,7 @@ module Belt
|
|
|
35
36
|
return [] unless File.exist?(schema_file)
|
|
36
37
|
|
|
37
38
|
content = File.read(schema_file)
|
|
38
|
-
singular =
|
|
39
|
+
singular = Belt::Inflector.singularize(name)
|
|
39
40
|
|
|
40
41
|
# Extract fields from model block
|
|
41
42
|
if content =~ /model :#{singular} do\n(.*?)\n\s*end/m
|
|
@@ -53,9 +54,9 @@ module Belt
|
|
|
53
54
|
def initialize(name, fields)
|
|
54
55
|
@name = name.downcase.gsub(/[^a-z0-9_]/, '_')
|
|
55
56
|
@fields = fields
|
|
56
|
-
@
|
|
57
|
-
@
|
|
58
|
-
@class_name = @singular_name
|
|
57
|
+
@singular_name = Belt::Inflector.singularize(@name)
|
|
58
|
+
@resource_name = Belt::Inflector.pluralize(@singular_name)
|
|
59
|
+
@class_name = Belt::Inflector.classify(@singular_name)
|
|
59
60
|
end
|
|
60
61
|
|
|
61
62
|
def generate
|
|
@@ -65,9 +66,10 @@ module Belt
|
|
|
65
66
|
end
|
|
66
67
|
|
|
67
68
|
pages_dir = "frontend/src/pages/#{@resource_name}"
|
|
69
|
+
@plural_class_name = Belt::Inflector.camelize(@resource_name)
|
|
68
70
|
FileUtils.mkdir_p(pages_dir)
|
|
69
71
|
|
|
70
|
-
write_template('Index.jsx.erb', "#{pages_dir}/#{@
|
|
72
|
+
write_template('Index.jsx.erb', "#{pages_dir}/#{@plural_class_name}Index.jsx")
|
|
71
73
|
write_template('Show.jsx.erb', "#{pages_dir}/#{@class_name}Show.jsx")
|
|
72
74
|
write_template('New.jsx.erb', "#{pages_dir}/#{@class_name}New.jsx")
|
|
73
75
|
write_template('Edit.jsx.erb', "#{pages_dir}/#{@class_name}Edit.jsx")
|
|
@@ -77,7 +79,7 @@ module Belt
|
|
|
77
79
|
|
|
78
80
|
puts "\n✓ Views for '#{@singular_name}' generated!"
|
|
79
81
|
puts "\nFiles created:"
|
|
80
|
-
puts " #{pages_dir}/#{@
|
|
82
|
+
puts " #{pages_dir}/#{@plural_class_name}Index.jsx"
|
|
81
83
|
puts " #{pages_dir}/#{@class_name}Show.jsx"
|
|
82
84
|
puts " #{pages_dir}/#{@class_name}New.jsx"
|
|
83
85
|
puts " #{pages_dir}/#{@class_name}Edit.jsx"
|
|
@@ -100,16 +102,17 @@ module Belt
|
|
|
100
102
|
|
|
101
103
|
content = File.read(app_jsx)
|
|
102
104
|
pages_dir = @resource_name
|
|
105
|
+
plural_class = @plural_class_name || Belt::Inflector.camelize(@resource_name)
|
|
103
106
|
|
|
104
107
|
import_lines = [
|
|
105
|
-
"import #{
|
|
108
|
+
"import #{plural_class}Index from './pages/#{pages_dir}/#{plural_class}Index'",
|
|
106
109
|
"import #{@class_name}Show from './pages/#{pages_dir}/#{@class_name}Show'",
|
|
107
110
|
"import #{@class_name}New from './pages/#{pages_dir}/#{@class_name}New'",
|
|
108
111
|
"import #{@class_name}Edit from './pages/#{pages_dir}/#{@class_name}Edit'"
|
|
109
112
|
]
|
|
110
113
|
|
|
111
114
|
route_lines = [
|
|
112
|
-
" <Route path=\"/#{@resource_name}\" element={<#{
|
|
115
|
+
" <Route path=\"/#{@resource_name}\" element={<#{plural_class}Index />} />",
|
|
113
116
|
" <Route path=\"/#{@resource_name}/new\" element={<#{@class_name}New />} />",
|
|
114
117
|
" <Route path=\"/#{@resource_name}/:id\" element={<#{@class_name}Show />} />",
|
|
115
118
|
" <Route path=\"/#{@resource_name}/:id/edit\" element={<#{@class_name}Edit />} />"
|
data/lib/belt/cli.rb
CHANGED
|
@@ -5,6 +5,7 @@ require_relative 'root'
|
|
|
5
5
|
require_relative 'cli/env_resolver'
|
|
6
6
|
require_relative 'cli/new_command'
|
|
7
7
|
require_relative 'cli/generate_command'
|
|
8
|
+
require_relative 'cli/destroy_command'
|
|
8
9
|
require_relative 'cli/frontend_command'
|
|
9
10
|
require_relative 'cli/frontend_setup_command'
|
|
10
11
|
require_relative 'cli/frontend_deploy_command'
|
|
@@ -22,6 +23,7 @@ module Belt
|
|
|
22
23
|
COMMANDS_DEFINITION = {
|
|
23
24
|
'new' => Belt::CLI::NewCommand,
|
|
24
25
|
%w[generate g] => Belt::CLI::GenerateCommand,
|
|
26
|
+
%w[destroy d] => Belt::CLI::DestroyCommand,
|
|
25
27
|
'routes' => Belt::CLI::RoutesCommand,
|
|
26
28
|
%w[console c] => Belt::CLI::ConsoleCommand,
|
|
27
29
|
%w[tasks --tasks -T] => Belt::CLI::TasksCommand,
|
|
@@ -45,7 +47,21 @@ module Belt
|
|
|
45
47
|
exit 1
|
|
46
48
|
end
|
|
47
49
|
|
|
48
|
-
#
|
|
50
|
+
# `belt destroy` is ambiguous: could be terraform destroy or belt destroy <generator>.
|
|
51
|
+
# Route to DestroyCommand if the first arg is a known generator or help flag.
|
|
52
|
+
if command == 'destroy'
|
|
53
|
+
if args.empty? || args.first =~ /\A-/
|
|
54
|
+
# belt destroy --help → DestroyCommand help
|
|
55
|
+
# belt destroy (no args) → terraform destroy (needs env)
|
|
56
|
+
if args.include?('--help') || args.include?('-h')
|
|
57
|
+
return DestroyCommand.run(args)
|
|
58
|
+
end
|
|
59
|
+
elsif DestroyCommand::GENERATORS.include?(args.first)
|
|
60
|
+
return DestroyCommand.run(args)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Terraform shorthand: belt init wups, belt plan wups, belt apply wups, belt destroy wups
|
|
49
65
|
return Belt::CLI::TerraformCommand.run(command, args) if TERRAFORM_ACTIONS.include?(command)
|
|
50
66
|
|
|
51
67
|
handler = COMMANDS[command]
|
|
@@ -71,10 +87,14 @@ module Belt
|
|
|
71
87
|
|
|
72
88
|
Commands:
|
|
73
89
|
new <app_name> [--frontend react] Create a new Belt application
|
|
74
|
-
generate <
|
|
90
|
+
generate <scaffold|model|controller> <name> Generate components
|
|
75
91
|
generate frontend <react|vue|svelte> Scaffold a frontend app
|
|
76
92
|
generate views <resource> [fields...] Generate React pages for REST actions
|
|
77
93
|
generate environment <name> Create a new environment
|
|
94
|
+
destroy <scaffold|model|controller> <name> Remove generated components
|
|
95
|
+
destroy frontend Remove the frontend/ directory
|
|
96
|
+
destroy views <resource> Remove React pages for a resource
|
|
97
|
+
destroy environment <name> Remove an environment directory
|
|
78
98
|
server Start local dev server (frontend)
|
|
79
99
|
s Alias for server
|
|
80
100
|
deploy [environment] Deploy to AWS (init → plan → apply)
|
|
@@ -106,7 +126,8 @@ module Belt
|
|
|
106
126
|
|
|
107
127
|
Examples:
|
|
108
128
|
belt new blog --frontend react
|
|
109
|
-
belt generate
|
|
129
|
+
belt generate scaffold post title:string content:text status:string
|
|
130
|
+
belt destroy scaffold post
|
|
110
131
|
belt generate frontend react
|
|
111
132
|
belt server # Start local frontend server
|
|
112
133
|
belt deploy # Deploy dev to AWS
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support/core_ext/string/inflections'
|
|
4
|
+
require 'active_support/inflector'
|
|
5
|
+
|
|
6
|
+
# Add common inflection rules that ActiveSupport doesn't include by default
|
|
7
|
+
ActiveSupport::Inflector.inflections(:en) do |inflect|
|
|
8
|
+
inflect.irregular 'hero', 'heroes'
|
|
9
|
+
inflect.irregular 'potato', 'potatoes'
|
|
10
|
+
inflect.irregular 'volcano', 'volcanoes'
|
|
11
|
+
inflect.irregular 'echo', 'echoes'
|
|
12
|
+
inflect.irregular 'embargo', 'embargoes'
|
|
13
|
+
inflect.irregular 'veto', 'vetoes'
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
module Belt
|
|
17
|
+
# Provides proper English inflection rules (pluralize, singularize, classify, etc.)
|
|
18
|
+
# via ActiveSupport::Inflector. Used throughout Belt for resource name derivation.
|
|
19
|
+
#
|
|
20
|
+
# ActiveSupport is already a transitive dependency (via activeitem → activemodel → activesupport)
|
|
21
|
+
# so this adds zero new weight.
|
|
22
|
+
module Inflector
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
# "hero" → "heroes", "post" → "posts", "person" → "people"
|
|
26
|
+
def pluralize(word)
|
|
27
|
+
word.to_s.pluralize
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# "heroes" → "hero", "posts" → "post", "people" → "person"
|
|
31
|
+
def singularize(word)
|
|
32
|
+
word.to_s.singularize
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# "blog_post" → "BlogPost", "hero" → "Hero"
|
|
36
|
+
def classify(word)
|
|
37
|
+
singularize(word).split('_').map(&:capitalize).join
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# "blog_posts" → "BlogPosts", "heroes" → "Heroes" (no singularization)
|
|
41
|
+
def camelize(word)
|
|
42
|
+
word.to_s.split('_').map(&:capitalize).join
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# "BlogPost" → "blog_post"
|
|
46
|
+
def underscore(word)
|
|
47
|
+
word.to_s
|
|
48
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
49
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
50
|
+
.downcase
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# "heroes_controller" → "heroes", "PostsController" → "posts"
|
|
54
|
+
def resource_name(word)
|
|
55
|
+
pluralize(word.to_s.sub(/_?controller$/i, '').downcase)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
data/lib/belt/lambda_handler.rb
CHANGED
|
@@ -41,6 +41,17 @@ module Belt
|
|
|
41
41
|
subdir = File.join(controllers_dir, child)
|
|
42
42
|
Belt.controller_paths << subdir if File.directory?(subdir)
|
|
43
43
|
end
|
|
44
|
+
|
|
45
|
+
# Auto-load all models (application_record first, then the rest)
|
|
46
|
+
models_dir = File.join(File.dirname(caller_file), 'models')
|
|
47
|
+
if File.directory?(models_dir)
|
|
48
|
+
all_models = Dir[File.join(models_dir, '**', '*.rb')].sort
|
|
49
|
+
app_record = all_models.find { |f| File.basename(f) == 'application_record.rb' }
|
|
50
|
+
rest = all_models - [app_record].compact
|
|
51
|
+
|
|
52
|
+
require app_record if app_record
|
|
53
|
+
rest.each { |f| require f }
|
|
54
|
+
end
|
|
44
55
|
end
|
|
45
56
|
|
|
46
57
|
# API Gateway Lambda handler.
|