belt 0.1.7 → 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/destroy_command.rb +40 -7
- data/lib/belt/cli/frontend_setup_command.rb +30 -0
- data/lib/belt/cli/generate_command.rb +35 -24
- data/lib/belt/cli/new_command.rb +0 -2
- 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/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/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 +16 -2
- data/lib/templates/new_app/lambda/models/concerns/timestampable.rb.erb +0 -23
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ebbefe3f44abda51ac10b219cda18678ca834d06d8eab39e31beb3e48e99b25a
|
|
4
|
+
data.tar.gz: 3233b91c7297982ef7f5037331ef003c3637ebc6dccf4321b4a5f758ed57f1d9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: af38bf5330f69577f37ab34a0bd7510b5d84aa14969efd8975ac6875ea85450aaa741beeb71bf3f51f627af11c0c5445806081156706cdfd4901fba4301e33ed
|
|
7
|
+
data.tar.gz: 2eade1680ebfe76e78ab38e38d273565cf34eb1f1789427d358706ac2c4c128a5f86448a5eb2a146161be8c912bf53f9229fceb0904719c9df5ef3444dc3481e
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require 'fileutils'
|
|
4
4
|
require_relative 'app_detection'
|
|
5
|
+
require_relative 'tables_command'
|
|
6
|
+
require_relative '../inflector'
|
|
5
7
|
|
|
6
8
|
module Belt
|
|
7
9
|
module CLI
|
|
@@ -93,13 +95,9 @@ module Belt
|
|
|
93
95
|
@name = name&.downcase&.gsub(/[^a-z0-9_]/, '_')
|
|
94
96
|
@fields = fields
|
|
95
97
|
@app_name = detect_namespace
|
|
96
|
-
@
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
@singular_name = if @name
|
|
100
|
-
@name.end_with?('s') ? @name.chomp('s') : @name
|
|
101
|
-
end
|
|
102
|
-
@class_name = @singular_name&.split('_')&.map(&:capitalize)&.join
|
|
98
|
+
@singular_name = @name ? Belt::Inflector.singularize(@name) : nil
|
|
99
|
+
@resource_name = @singular_name ? Belt::Inflector.pluralize(@singular_name) : nil
|
|
100
|
+
@class_name = @singular_name ? Belt::Inflector.classify(@singular_name) : nil
|
|
103
101
|
@removed = []
|
|
104
102
|
@updated = []
|
|
105
103
|
end
|
|
@@ -124,6 +122,7 @@ module Belt
|
|
|
124
122
|
destroy_controller
|
|
125
123
|
remove_routes
|
|
126
124
|
remove_schema
|
|
125
|
+
sync_tables
|
|
127
126
|
destroy_views
|
|
128
127
|
puts "\n✓ Scaffold '#{@singular_name}' destroyed!"
|
|
129
128
|
end
|
|
@@ -157,6 +156,17 @@ module Belt
|
|
|
157
156
|
FileUtils.rm_rf(dir)
|
|
158
157
|
@removed << dir
|
|
159
158
|
puts " remove #{dir}/"
|
|
159
|
+
|
|
160
|
+
# Remove frontend.tf and revert frontend_urls in main.tf for each environment
|
|
161
|
+
Dir.glob('infrastructure/*/frontend.tf').each do |frontend_tf|
|
|
162
|
+
FileUtils.rm_f(frontend_tf)
|
|
163
|
+
puts " remove #{frontend_tf}"
|
|
164
|
+
|
|
165
|
+
env_dir = File.dirname(frontend_tf)
|
|
166
|
+
main_tf = File.join(env_dir, 'main.tf')
|
|
167
|
+
revert_main_tf_frontend_urls(main_tf)
|
|
168
|
+
end
|
|
169
|
+
|
|
160
170
|
puts "\n✓ Frontend removed!"
|
|
161
171
|
else
|
|
162
172
|
puts '✗ No frontend/ directory found.'
|
|
@@ -164,10 +174,29 @@ module Belt
|
|
|
164
174
|
end
|
|
165
175
|
end
|
|
166
176
|
|
|
177
|
+
def revert_main_tf_frontend_urls(main_tf)
|
|
178
|
+
return unless File.exist?(main_tf)
|
|
179
|
+
|
|
180
|
+
content = File.read(main_tf)
|
|
181
|
+
return unless content.include?('aws_cloudfront_distribution.frontend.domain_name')
|
|
182
|
+
|
|
183
|
+
# Replace the multi-line concat block back to a simple conditional
|
|
184
|
+
replaced = content.sub(
|
|
185
|
+
/^\s*frontend_urls\s*=\s*concat\(\n(?:.*\n)*?\s*\)\s*\n/,
|
|
186
|
+
" frontend_urls = var.environment == \"prod\" ? [] : [\"http://localhost:3000\"]\n"
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
if replaced != content
|
|
190
|
+
File.write(main_tf, replaced)
|
|
191
|
+
puts " update #{main_tf} (reverted frontend_urls)"
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
167
195
|
def destroy_views
|
|
168
196
|
return unless @resource_name
|
|
169
197
|
|
|
170
198
|
pages_dir = "frontend/src/pages/#{@resource_name}"
|
|
199
|
+
|
|
171
200
|
if Dir.exist?(pages_dir)
|
|
172
201
|
FileUtils.rm_rf(pages_dir)
|
|
173
202
|
@removed << pages_dir
|
|
@@ -255,6 +284,10 @@ module Belt
|
|
|
255
284
|
end
|
|
256
285
|
end
|
|
257
286
|
|
|
287
|
+
def sync_tables
|
|
288
|
+
Belt::CLI::TablesCommand.sync_all_environments
|
|
289
|
+
end
|
|
290
|
+
|
|
258
291
|
def remove_view_routes
|
|
259
292
|
app_jsx = 'frontend/src/App.jsx'
|
|
260
293
|
return unless File.exist?(app_jsx)
|
|
@@ -39,6 +39,7 @@ module Belt
|
|
|
39
39
|
def run
|
|
40
40
|
validate!
|
|
41
41
|
generate_frontend_tf
|
|
42
|
+
update_main_tf_frontend_urls
|
|
42
43
|
return if @quiet
|
|
43
44
|
|
|
44
45
|
puts "\n✓ Frontend infrastructure generated for '#{@env}'!"
|
|
@@ -62,6 +63,35 @@ module Belt
|
|
|
62
63
|
File.write(dest, content)
|
|
63
64
|
puts " create #{dest}" unless @quiet
|
|
64
65
|
end
|
|
66
|
+
|
|
67
|
+
def update_main_tf_frontend_urls
|
|
68
|
+
main_tf = File.join(@env_dir, 'main.tf')
|
|
69
|
+
return unless File.exist?(main_tf)
|
|
70
|
+
|
|
71
|
+
content = File.read(main_tf)
|
|
72
|
+
cloudfront_url = '"https://${aws_cloudfront_distribution.frontend.domain_name}"'
|
|
73
|
+
|
|
74
|
+
# Already patched — skip
|
|
75
|
+
return if content.include?('aws_cloudfront_distribution.frontend.domain_name')
|
|
76
|
+
|
|
77
|
+
# Match any frontend_urls line (hardcoded list or conditional expression)
|
|
78
|
+
new_frontend_urls = <<~HCL.chomp
|
|
79
|
+
frontend_urls = concat(
|
|
80
|
+
[#{cloudfront_url}],
|
|
81
|
+
var.environment == "prod" ? [] : ["http://localhost:3000"]
|
|
82
|
+
)
|
|
83
|
+
HCL
|
|
84
|
+
|
|
85
|
+
replaced = content.sub(
|
|
86
|
+
/^(\s*)frontend_urls\s*=\s*.+$/,
|
|
87
|
+
new_frontend_urls
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
if replaced != content
|
|
91
|
+
File.write(main_tf, replaced)
|
|
92
|
+
puts " update #{main_tf} (added CloudFront URL to frontend_urls)" unless @quiet
|
|
93
|
+
end
|
|
94
|
+
end
|
|
65
95
|
end
|
|
66
96
|
end
|
|
67
97
|
end
|
|
@@ -5,7 +5,9 @@ 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
|
|
@@ -25,9 +27,9 @@ module Belt
|
|
|
25
27
|
['--skip-views', 'Skip generating frontend view pages']
|
|
26
28
|
],
|
|
27
29
|
examples: [
|
|
28
|
-
['belt g scaffold post title
|
|
29
|
-
['belt g scaffold comment author
|
|
30
|
-
['belt g scaffold task --skip-views'
|
|
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:
|
|
@@ -46,8 +48,8 @@ module Belt
|
|
|
46
48
|
usage: 'belt generate model <name> [field:type ...]',
|
|
47
49
|
options: [],
|
|
48
50
|
examples: [
|
|
49
|
-
['belt g model user email
|
|
50
|
-
['belt g model event title
|
|
51
|
+
['belt g model user email name'],
|
|
52
|
+
['belt g model event title starts_at:datetime']
|
|
51
53
|
],
|
|
52
54
|
notes: <<~NOTES
|
|
53
55
|
Creates:
|
|
@@ -59,8 +61,8 @@ module Belt
|
|
|
59
61
|
usage: 'belt generate controller <name>',
|
|
60
62
|
options: [],
|
|
61
63
|
examples: [
|
|
62
|
-
['belt g controller posts'
|
|
63
|
-
['belt g controller admin/users'
|
|
64
|
+
['belt g controller posts'],
|
|
65
|
+
['belt g controller admin/users']
|
|
64
66
|
],
|
|
65
67
|
notes: <<~NOTES
|
|
66
68
|
Creates:
|
|
@@ -132,7 +134,7 @@ module Belt
|
|
|
132
134
|
errors << 'must only contain letters, numbers, and underscores' unless name.match?(/\A[a-zA-Z][a-zA-Z0-9_]*\z/)
|
|
133
135
|
errors << 'must not start or end with an underscore' if name.match?(/\A_|_\z/)
|
|
134
136
|
errors << 'must not contain consecutive underscores' if name.include?('__')
|
|
135
|
-
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))
|
|
136
138
|
errors << "is a reserved name" if RESERVED_NAMES.include?(name.downcase)
|
|
137
139
|
|
|
138
140
|
return if errors.empty?
|
|
@@ -161,14 +163,15 @@ module Belt
|
|
|
161
163
|
|
|
162
164
|
Field Types:
|
|
163
165
|
#{FIELD_TYPES.join(', ')}
|
|
166
|
+
(defaults to string if omitted)
|
|
164
167
|
|
|
165
168
|
Examples:
|
|
166
|
-
belt g scaffold post title
|
|
167
|
-
belt g model user email
|
|
169
|
+
belt g scaffold post title body:text status
|
|
170
|
+
belt g model user email name
|
|
168
171
|
belt g controller comments
|
|
169
172
|
belt g environment staging
|
|
170
173
|
belt g frontend react
|
|
171
|
-
belt g views post title
|
|
174
|
+
belt g views post title body:text
|
|
172
175
|
|
|
173
176
|
Run 'belt generate <generator> --help' for detailed help on a specific generator.
|
|
174
177
|
HELP
|
|
@@ -197,21 +200,18 @@ module Belt
|
|
|
197
200
|
|
|
198
201
|
puts "\nField Types:"
|
|
199
202
|
puts " #{FIELD_TYPES.join(', ')}"
|
|
203
|
+
puts " (defaults to string if omitted)"
|
|
200
204
|
|
|
201
205
|
puts "\nExamples:"
|
|
202
206
|
info[:examples].each do |cmd, desc|
|
|
203
|
-
|
|
204
|
-
|
|
207
|
+
if desc
|
|
208
|
+
puts " #{cmd} — #{desc}"
|
|
209
|
+
else
|
|
210
|
+
puts " #{cmd}"
|
|
211
|
+
end
|
|
205
212
|
end
|
|
206
213
|
|
|
207
214
|
puts "\n#{info[:notes]}" if info[:notes]
|
|
208
|
-
|
|
209
|
-
puts "Naming Rules:"
|
|
210
|
-
puts " - Must start with a letter"
|
|
211
|
-
puts " - At least 2 characters"
|
|
212
|
-
puts " - Letters, numbers, and single underscores only"
|
|
213
|
-
puts " - No leading/trailing/consecutive underscores"
|
|
214
|
-
puts " - Cannot use reserved names (help, new, create, destroy, etc.)"
|
|
215
215
|
end
|
|
216
216
|
|
|
217
217
|
def initialize(generator, name, fields, skip_views: false)
|
|
@@ -221,15 +221,15 @@ module Belt
|
|
|
221
221
|
@skip_views = skip_views
|
|
222
222
|
@app_name = detect_namespace
|
|
223
223
|
@module_name = @app_name.split(/[-_]/).map(&:capitalize).join
|
|
224
|
-
@
|
|
225
|
-
@
|
|
226
|
-
@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)
|
|
227
227
|
end
|
|
228
228
|
|
|
229
229
|
def generate
|
|
230
230
|
case @generator
|
|
231
231
|
when 'scaffold' then generate_resource
|
|
232
|
-
when 'model' then
|
|
232
|
+
when 'model' then generate_model_standalone
|
|
233
233
|
when 'controller' then generate_controller
|
|
234
234
|
end
|
|
235
235
|
end
|
|
@@ -241,6 +241,7 @@ module Belt
|
|
|
241
241
|
generate_controller
|
|
242
242
|
inject_routes
|
|
243
243
|
inject_schema
|
|
244
|
+
sync_tables
|
|
244
245
|
generate_views_if_frontend
|
|
245
246
|
puts "\n✓ Scaffold '#{@singular_name}' generated!"
|
|
246
247
|
puts "\nFiles created/updated:"
|
|
@@ -252,6 +253,12 @@ module Belt
|
|
|
252
253
|
puts " frontend/src/pages/#{@resource_name}/ (views)" if Dir.exist?('frontend/src')
|
|
253
254
|
end
|
|
254
255
|
|
|
256
|
+
def generate_model_standalone
|
|
257
|
+
generate_model
|
|
258
|
+
inject_schema
|
|
259
|
+
sync_tables
|
|
260
|
+
end
|
|
261
|
+
|
|
255
262
|
def generate_model
|
|
256
263
|
dest = "lambda/models/#{@singular_name}.rb"
|
|
257
264
|
write_template('model.rb.erb', dest)
|
|
@@ -373,6 +380,10 @@ module Belt
|
|
|
373
380
|
puts " update #{schema_file}"
|
|
374
381
|
end
|
|
375
382
|
|
|
383
|
+
def sync_tables
|
|
384
|
+
Belt::CLI::TablesCommand.sync_all_environments
|
|
385
|
+
end
|
|
386
|
+
|
|
376
387
|
def write_template(template_name, dest_path)
|
|
377
388
|
template_path = File.join(TEMPLATE_DIR, template_name)
|
|
378
389
|
FileUtils.mkdir_p(File.dirname(dest_path))
|
data/lib/belt/cli/new_command.rb
CHANGED
|
@@ -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",
|
|
@@ -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 />} />"
|
|
@@ -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.
|
data/lib/belt/route_dsl.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'inflector'
|
|
4
|
+
|
|
3
5
|
module Belt
|
|
4
6
|
# DSL for defining API Gateway routes.
|
|
5
7
|
# Ported from terraform-provider-conveyor-belt/scripts/lib/route_dsl.rb
|
|
@@ -256,17 +258,7 @@ module Belt
|
|
|
256
258
|
end
|
|
257
259
|
|
|
258
260
|
def singularize(word)
|
|
259
|
-
|
|
260
|
-
"#{word[0..-4]}y"
|
|
261
|
-
elsif word.end_with?('xes') || word.end_with?('zes') || word.end_with?('ses')
|
|
262
|
-
word[0..-3]
|
|
263
|
-
elsif word.end_with?('ches') || word.end_with?('shes')
|
|
264
|
-
word[0..-3]
|
|
265
|
-
elsif word.end_with?('s') && !word.end_with?('ss')
|
|
266
|
-
word[0..-2]
|
|
267
|
-
else
|
|
268
|
-
word
|
|
269
|
-
end
|
|
261
|
+
Belt::Inflector.singularize(word)
|
|
270
262
|
end
|
|
271
263
|
end
|
|
272
264
|
|
data/lib/belt/table_inference.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'inflector'
|
|
4
|
+
|
|
3
5
|
module Belt
|
|
4
6
|
# Infers DynamoDB table names from route paths by matching against
|
|
5
7
|
# tables defined in a Terraform file containing aws_dynamodb_table resources.
|
|
@@ -45,27 +47,11 @@ module Belt
|
|
|
45
47
|
end
|
|
46
48
|
|
|
47
49
|
def pluralize(word)
|
|
48
|
-
|
|
49
|
-
"#{word[0..-2]}ies"
|
|
50
|
-
elsif word.end_with?('s', 'x', 'z', 'ch', 'sh')
|
|
51
|
-
"#{word}es"
|
|
52
|
-
else
|
|
53
|
-
"#{word}s"
|
|
54
|
-
end
|
|
50
|
+
Belt::Inflector.pluralize(word)
|
|
55
51
|
end
|
|
56
52
|
|
|
57
53
|
def singularize(word)
|
|
58
|
-
|
|
59
|
-
"#{word[0..-4]}y"
|
|
60
|
-
elsif word.end_with?('xes', 'zes', 'ses')
|
|
61
|
-
word[0..-3]
|
|
62
|
-
elsif word.end_with?('ches', 'shes')
|
|
63
|
-
word[0..-3]
|
|
64
|
-
elsif word.end_with?('s') && !word.end_with?('ss')
|
|
65
|
-
word[0..-2]
|
|
66
|
-
else
|
|
67
|
-
word
|
|
68
|
-
end
|
|
54
|
+
Belt::Inflector.singularize(word)
|
|
69
55
|
end
|
|
70
56
|
end
|
|
71
57
|
end
|
data/lib/belt/version.rb
CHANGED
|
@@ -38,7 +38,7 @@ resource "conveyor_belt" "main" {
|
|
|
38
38
|
app_name = var.app_name
|
|
39
39
|
lambda_source_dir = "${path.module}/../../lambda"
|
|
40
40
|
lambda_shared_dirs = ["controllers", "helpers", "lib", "models", "views"]
|
|
41
|
-
frontend_urls = ["http://localhost:3000"]
|
|
41
|
+
frontend_urls = var.environment == "prod" ? [] : ["http://localhost:3000"]
|
|
42
42
|
friendly_errors = var.environment != "prod"
|
|
43
43
|
|
|
44
44
|
# Per-lambda configuration: env vars, timeouts, memory
|
|
@@ -15,4 +15,10 @@ end
|
|
|
15
15
|
|
|
16
16
|
# Load lib and models
|
|
17
17
|
Dir[File.join(__dir__, '..', 'lib', '**', '*.rb')].sort.each { |f| require f }
|
|
18
|
-
|
|
18
|
+
|
|
19
|
+
models_dir = File.join(__dir__, '..', 'models')
|
|
20
|
+
all_models = Dir[File.join(models_dir, '**', '*.rb')].sort
|
|
21
|
+
app_record = all_models.find { |f| File.basename(f) == 'application_record.rb' }
|
|
22
|
+
rest = all_models - [app_record].compact
|
|
23
|
+
require app_record if app_record
|
|
24
|
+
rest.each { |f| require f }
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: belt
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.8
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Stowzilla
|
|
@@ -23,6 +23,20 @@ dependencies:
|
|
|
23
23
|
- - "~>"
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
25
|
version: '0.0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: activesupport
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '7.0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '7.0'
|
|
26
40
|
- !ruby/object:Gem::Dependency
|
|
27
41
|
name: lambda_loadout
|
|
28
42
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -80,6 +94,7 @@ files:
|
|
|
80
94
|
- lib/belt/helpers/cors_origin.rb
|
|
81
95
|
- lib/belt/helpers/error_logging.rb
|
|
82
96
|
- lib/belt/helpers/response.rb
|
|
97
|
+
- lib/belt/inflector.rb
|
|
83
98
|
- lib/belt/lambda_handler.rb
|
|
84
99
|
- lib/belt/observability.rb
|
|
85
100
|
- lib/belt/parameters.rb
|
|
@@ -118,7 +133,6 @@ files:
|
|
|
118
133
|
- lib/templates/new_app/lambda/controllers/application_controller.rb.erb
|
|
119
134
|
- lib/templates/new_app/lambda/lib/routes/routes.rb.erb
|
|
120
135
|
- lib/templates/new_app/lambda/models/application_record.rb.erb
|
|
121
|
-
- lib/templates/new_app/lambda/models/concerns/timestampable.rb.erb
|
|
122
136
|
- lib/templates/views/Edit.jsx.erb
|
|
123
137
|
- lib/templates/views/Form.jsx.erb
|
|
124
138
|
- lib/templates/views/Index.jsx.erb
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Concerns
|
|
4
|
-
module Timestampable
|
|
5
|
-
def self.included(base)
|
|
6
|
-
base.attr_accessor :created_at, :updated_at
|
|
7
|
-
base.set_callback :create, :before, :set_timestamps
|
|
8
|
-
base.set_callback :update, :before, :set_updated_at
|
|
9
|
-
end
|
|
10
|
-
|
|
11
|
-
private
|
|
12
|
-
|
|
13
|
-
def set_timestamps
|
|
14
|
-
now = Time.now.utc.iso8601
|
|
15
|
-
self.created_at ||= now
|
|
16
|
-
self.updated_at = now
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
def set_updated_at
|
|
20
|
-
self.updated_at = Time.now.utc.iso8601
|
|
21
|
-
end
|
|
22
|
-
end
|
|
23
|
-
end
|