belt 0.0.7 → 0.1.1
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
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +29 -1
- data/README.md +150 -51
- data/exe/belt +6 -0
- data/lib/belt/action_router.rb +7 -1
- data/lib/belt/cli/app_detection.rb +16 -0
- data/lib/belt/cli/bucket_security.rb +122 -0
- data/lib/belt/cli/env_resolver.rb +15 -0
- data/lib/belt/cli/environment_command.rb +77 -0
- data/lib/belt/cli/frontend_command.rb +85 -0
- data/lib/belt/cli/frontend_deploy_command.rb +125 -0
- data/lib/belt/cli/frontend_setup_command.rb +64 -0
- data/lib/belt/cli/generate_command.rb +206 -0
- data/lib/belt/cli/new_command.rb +126 -0
- data/lib/belt/cli/routes_command/route_inference.rb +100 -0
- data/lib/belt/cli/routes_command/schema_loader.rb +71 -0
- data/lib/belt/cli/routes_command.rb +307 -0
- data/lib/belt/cli/setup_command.rb +261 -0
- data/lib/belt/cli/tables_command.rb +138 -0
- data/lib/belt/cli/tasks_command.rb +110 -0
- data/lib/belt/cli/terraform_command.rb +77 -0
- data/lib/belt/cli/views_command.rb +134 -0
- data/lib/belt/cli.rb +117 -0
- data/lib/belt/lambda_handler.rb +16 -0
- data/lib/belt/root.rb +26 -0
- data/lib/belt/route_dsl.rb +605 -0
- data/lib/belt/table_inference.rb +71 -0
- data/lib/belt/version.rb +1 -1
- data/lib/belt.rb +1 -0
- data/lib/templates/environment/backend.tf.erb +8 -0
- data/lib/templates/environment/main.tf.erb +42 -0
- data/lib/templates/environment/terraform.tfvars.erb +1 -0
- data/lib/templates/environment/variables.tf.erb +16 -0
- data/lib/templates/frontend/react/index.html.erb +12 -0
- data/lib/templates/frontend/react/package.json.erb +20 -0
- data/lib/templates/frontend/react/src/App.jsx +14 -0
- data/lib/templates/frontend/react/src/index.css +10 -0
- data/lib/templates/frontend/react/src/lib/apiClient.js.erb +19 -0
- data/lib/templates/frontend/react/src/main.jsx +10 -0
- data/lib/templates/frontend/react/src/pages/Home.jsx.erb +10 -0
- data/lib/templates/frontend/react/vite.config.js +8 -0
- data/lib/templates/frontend_infra/frontend.tf.erb +159 -0
- data/lib/templates/generate/controller.rb.erb +59 -0
- data/lib/templates/generate/model.rb.erb +20 -0
- data/lib/templates/new_app/AGENTS.md.erb +130 -0
- data/lib/templates/new_app/Gemfile.erb +5 -0
- data/lib/templates/new_app/README.md.erb +25 -0
- data/lib/templates/new_app/Rakefile.erb +12 -0
- data/lib/templates/new_app/gitignore.erb +14 -0
- data/lib/templates/new_app/infrastructure/routes.tf.rb.erb +5 -0
- data/lib/templates/new_app/infrastructure/schema.tf.rb.erb +9 -0
- data/lib/templates/new_app/lambda/Gemfile.erb +7 -0
- data/lib/templates/new_app/lambda/api.rb.erb +22 -0
- data/lib/templates/new_app/lambda/controllers/application_controller.rb.erb +6 -0
- data/lib/templates/new_app/lambda/lib/routes/routes.rb.erb +11 -0
- data/lib/templates/new_app/lambda/models/application_record.rb.erb +6 -0
- data/lib/templates/new_app/lambda/models/concerns/timestampable.rb.erb +23 -0
- data/lib/templates/views/Edit.jsx.erb +38 -0
- data/lib/templates/views/Form.jsx.erb +34 -0
- data/lib/templates/views/Index.jsx.erb +39 -0
- data/lib/templates/views/New.jsx.erb +26 -0
- data/lib/templates/views/Show.jsx.erb +46 -0
- data.tar.gz.sig +0 -0
- metadata +73 -3
- metadata.gz.sig +0 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'shellwords'
|
|
4
|
+
require 'open3'
|
|
5
|
+
require_relative 'app_detection'
|
|
6
|
+
require_relative 'env_resolver'
|
|
7
|
+
|
|
8
|
+
module Belt
|
|
9
|
+
module CLI
|
|
10
|
+
class FrontendDeployCommand
|
|
11
|
+
include AppDetection
|
|
12
|
+
|
|
13
|
+
def self.run(args)
|
|
14
|
+
env = EnvResolver.resolve(args)
|
|
15
|
+
|
|
16
|
+
if env.nil?
|
|
17
|
+
puts 'Usage: belt deploy frontend <environment>'
|
|
18
|
+
puts "\nBuilds the frontend app and deploys to S3 + invalidates CloudFront."
|
|
19
|
+
puts 'You can also set BELT_ENV to skip the environment argument.'
|
|
20
|
+
puts "\nExamples:"
|
|
21
|
+
puts ' belt deploy frontend wups'
|
|
22
|
+
puts ' belt deploy frontend dev01'
|
|
23
|
+
puts ' BELT_ENV=wups belt deploy frontend'
|
|
24
|
+
exit 1
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
new(env).run
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def initialize(env)
|
|
31
|
+
@env = env
|
|
32
|
+
@app_name = detect_app_name
|
|
33
|
+
@env_dir = "infrastructure/#{@env}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def run
|
|
37
|
+
validate!
|
|
38
|
+
build_frontend
|
|
39
|
+
sync_to_s3
|
|
40
|
+
invalidate_cloudfront
|
|
41
|
+
url = fetch_frontend_url
|
|
42
|
+
puts "\n✅ Frontend deployed to #{@env}!"
|
|
43
|
+
puts " #{url}" if url
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def validate!
|
|
49
|
+
unless Dir.exist?('frontend')
|
|
50
|
+
abort 'Error: No frontend/ directory found. Run `belt generate frontend react` first.'
|
|
51
|
+
end
|
|
52
|
+
return if File.exist?('frontend/package.json')
|
|
53
|
+
|
|
54
|
+
abort 'Error: frontend/package.json not found.'
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def build_frontend
|
|
58
|
+
puts '📦 Installing dependencies...'
|
|
59
|
+
install_cmd = File.exist?('frontend/package-lock.json') ? %w[npm ci] : %w[npm install]
|
|
60
|
+
run!(*install_cmd, chdir: 'frontend')
|
|
61
|
+
|
|
62
|
+
puts '🏗️ Building frontend...'
|
|
63
|
+
api_url = fetch_api_url
|
|
64
|
+
env = api_url ? { 'VITE_API_URL' => api_url } : {}
|
|
65
|
+
run!(env, 'npm', 'run', 'build', chdir: 'frontend')
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def sync_to_s3
|
|
69
|
+
bucket = fetch_bucket_name
|
|
70
|
+
abort "Error: Could not determine S3 bucket. Run `belt apply #{@env}` first." unless bucket
|
|
71
|
+
|
|
72
|
+
puts "☁️ Deploying to S3... (#{bucket})"
|
|
73
|
+
|
|
74
|
+
# Hashed assets get immutable cache headers
|
|
75
|
+
run!('aws', 's3', 'sync', 'frontend/dist/', "s3://#{bucket}", '--delete',
|
|
76
|
+
'--size-only', '--cache-control', 'public, max-age=31536000, immutable',
|
|
77
|
+
'--exclude', 'index.html')
|
|
78
|
+
|
|
79
|
+
# index.html always revalidates
|
|
80
|
+
run!('aws', 's3', 'cp', 'frontend/dist/index.html', "s3://#{bucket}/index.html",
|
|
81
|
+
'--cache-control', 'no-cache')
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def invalidate_cloudfront
|
|
85
|
+
dist_id = fetch_distribution_id
|
|
86
|
+
unless dist_id
|
|
87
|
+
puts '⚠️ No CloudFront distribution found (skipping cache invalidation)'
|
|
88
|
+
return
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
puts '🔄 Invalidating CloudFront cache...'
|
|
92
|
+
run!('aws', 'cloudfront', 'create-invalidation', '--distribution-id', dist_id, '--paths', '/*',
|
|
93
|
+
out: File::NULL)
|
|
94
|
+
puts '✅ CloudFront cache invalidated'
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def fetch_api_url
|
|
98
|
+
output, status = Open3.capture2('terraform', 'output', '-raw', 'api_url', chdir: @env_dir)
|
|
99
|
+
status.success? && !output.strip.empty? ? output.strip : nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def fetch_bucket_name
|
|
103
|
+
output, status = Open3.capture2('terraform', 'output', '-raw', 'frontend_bucket_name', chdir: @env_dir)
|
|
104
|
+
status.success? && !output.strip.empty? ? output.strip : nil
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def fetch_distribution_id
|
|
108
|
+
output, status = Open3.capture2('terraform', 'output', '-raw', 'frontend_distribution_id', chdir: @env_dir)
|
|
109
|
+
status.success? && !output.strip.empty? ? output.strip : nil
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def fetch_frontend_url
|
|
113
|
+
output, status = Open3.capture2('terraform', 'output', '-raw', 'frontend_url', chdir: @env_dir)
|
|
114
|
+
status.success? && !output.strip.empty? ? output.strip : nil
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def run!(*args, **)
|
|
118
|
+
env = args.first.is_a?(Hash) ? args.shift : {}
|
|
119
|
+
return if system(env, *args, **)
|
|
120
|
+
|
|
121
|
+
abort "\n✗ Command failed: #{args.shelljoin}"
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'erb'
|
|
5
|
+
require_relative 'app_detection'
|
|
6
|
+
require_relative 'env_resolver'
|
|
7
|
+
|
|
8
|
+
module Belt
|
|
9
|
+
module CLI
|
|
10
|
+
class FrontendSetupCommand
|
|
11
|
+
TEMPLATE_DIR = File.expand_path('../../templates/frontend_infra', __dir__)
|
|
12
|
+
|
|
13
|
+
include AppDetection
|
|
14
|
+
|
|
15
|
+
def self.run(args)
|
|
16
|
+
env = EnvResolver.resolve(args)
|
|
17
|
+
|
|
18
|
+
if env.nil?
|
|
19
|
+
puts 'Usage: belt setup frontend <environment>'
|
|
20
|
+
puts "\nGenerates S3 + CloudFront Terraform for frontend hosting."
|
|
21
|
+
puts 'You can also set BELT_ENV to skip the environment argument.'
|
|
22
|
+
puts "\nExamples:"
|
|
23
|
+
puts ' belt setup frontend wups'
|
|
24
|
+
puts ' belt setup frontend dev01'
|
|
25
|
+
puts ' BELT_ENV=wups belt setup frontend'
|
|
26
|
+
exit 1
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
new(env).run
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def initialize(env)
|
|
33
|
+
@env = env
|
|
34
|
+
@app_name = detect_app_name
|
|
35
|
+
@env_dir = "infrastructure/#{@env}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def run
|
|
39
|
+
validate!
|
|
40
|
+
generate_frontend_tf
|
|
41
|
+
puts "\n✓ Frontend infrastructure generated for '#{@env}'!"
|
|
42
|
+
puts "\nRun `belt apply #{@env}` to create the S3 bucket and CloudFront distribution."
|
|
43
|
+
puts "Then `belt deploy frontend #{@env}` to build and deploy."
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def validate!
|
|
49
|
+
return if Dir.exist?(@env_dir)
|
|
50
|
+
|
|
51
|
+
abort "Error: Environment '#{@env}' not found at #{@env_dir}/.\n" \
|
|
52
|
+
"Create it with: belt generate environment #{@env}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def generate_frontend_tf
|
|
56
|
+
dest = File.join(@env_dir, 'frontend.tf')
|
|
57
|
+
template_path = File.join(TEMPLATE_DIR, 'frontend.tf.erb')
|
|
58
|
+
content = ERB.new(File.read(template_path), trim_mode: '-').result(binding)
|
|
59
|
+
File.write(dest, content)
|
|
60
|
+
puts " create #{dest}"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'erb'
|
|
5
|
+
require_relative 'app_detection'
|
|
6
|
+
require_relative 'environment_command'
|
|
7
|
+
require_relative 'frontend_command'
|
|
8
|
+
require_relative 'views_command'
|
|
9
|
+
|
|
10
|
+
module Belt
|
|
11
|
+
module CLI
|
|
12
|
+
class GenerateCommand
|
|
13
|
+
TEMPLATE_DIR = File.expand_path('../../templates/generate', __dir__)
|
|
14
|
+
GENERATORS = %w[resource model controller environment frontend views].freeze
|
|
15
|
+
|
|
16
|
+
include AppDetection
|
|
17
|
+
|
|
18
|
+
def self.run(args)
|
|
19
|
+
generator = args.shift
|
|
20
|
+
|
|
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'
|
|
28
|
+
exit 1
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
return Belt::CLI::EnvironmentCommand.run(args) if generator == 'environment'
|
|
32
|
+
|
|
33
|
+
return Belt::CLI::FrontendCommand.run(args) if generator == 'frontend'
|
|
34
|
+
|
|
35
|
+
return Belt::CLI::ViewsCommand.run(args) if generator == 'views'
|
|
36
|
+
|
|
37
|
+
name = args.shift
|
|
38
|
+
if name.nil? || name.empty?
|
|
39
|
+
puts "Usage: belt generate #{generator} <name> [field:type ...]"
|
|
40
|
+
exit 1
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
skip_views = args.delete('--skip-views')
|
|
44
|
+
fields = args.map { |arg| parse_field(arg) }
|
|
45
|
+
new(generator, name, fields, skip_views: skip_views).generate
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.parse_field(arg)
|
|
49
|
+
name, type = arg.split(':', 2)
|
|
50
|
+
{ name: name, type: type || 'string' }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def initialize(generator, name, fields, skip_views: false)
|
|
54
|
+
@generator = generator
|
|
55
|
+
@name = name.downcase.gsub(/[^a-z0-9_]/, '_')
|
|
56
|
+
@fields = fields
|
|
57
|
+
@skip_views = skip_views
|
|
58
|
+
@app_name = detect_app_name
|
|
59
|
+
@module_name = @app_name.split(/[-_]/).map(&:capitalize).join
|
|
60
|
+
@resource_name = @name.end_with?('s') ? @name : "#{@name}s"
|
|
61
|
+
@singular_name = @name.end_with?('s') ? @name.chomp('s') : @name
|
|
62
|
+
@class_name = @singular_name.split('_').map(&:capitalize).join
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def generate
|
|
66
|
+
case @generator
|
|
67
|
+
when 'resource' then generate_resource
|
|
68
|
+
when 'model' then generate_model
|
|
69
|
+
when 'controller' then generate_controller
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def generate_resource
|
|
76
|
+
generate_model
|
|
77
|
+
generate_controller
|
|
78
|
+
inject_routes
|
|
79
|
+
inject_schema
|
|
80
|
+
generate_views_if_frontend
|
|
81
|
+
puts "\n✓ Resource '#{@singular_name}' generated!"
|
|
82
|
+
puts "\nFiles created/updated:"
|
|
83
|
+
puts " lambda/models/#{@singular_name}.rb"
|
|
84
|
+
puts " lambda/controllers/#{@app_name}/#{@resource_name}_controller.rb"
|
|
85
|
+
puts ' infrastructure/routes.tf.rb (updated)'
|
|
86
|
+
puts ' infrastructure/schema.tf.rb (updated)'
|
|
87
|
+
puts " lambda/lib/routes/#{@app_name}_routes.rb (updated)"
|
|
88
|
+
puts " frontend/src/pages/#{@resource_name}/ (views)" if Dir.exist?('frontend/src')
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def generate_model
|
|
92
|
+
dest = "lambda/models/#{@singular_name}.rb"
|
|
93
|
+
write_template('model.rb.erb', dest)
|
|
94
|
+
puts " create #{dest}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def generate_controller
|
|
98
|
+
dest = "lambda/controllers/#{@app_name}/#{@resource_name}_controller.rb"
|
|
99
|
+
write_template('controller.rb.erb', dest)
|
|
100
|
+
puts " create #{dest}"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def inject_routes
|
|
104
|
+
routes_file = 'infrastructure/routes.tf.rb'
|
|
105
|
+
return unless File.exist?(routes_file)
|
|
106
|
+
|
|
107
|
+
content = File.read(routes_file)
|
|
108
|
+
tables_arg = @fields.any? ? ", tables: [:#{@resource_name}]" : ''
|
|
109
|
+
|
|
110
|
+
# Insert before the closing `end` of the namespace block
|
|
111
|
+
if content.include?('# resources :posts')
|
|
112
|
+
content.sub!('# resources :posts', "resources :#{@resource_name}#{tables_arg}")
|
|
113
|
+
elsif content.match?(/namespace :\w+[^\n]*do\n(\s+#[^\n]*\n)*\s+end/)
|
|
114
|
+
content.sub!(/^(\s+)(end\s*\z)/m, "\\1 resources :#{@resource_name}#{tables_arg}\n\\1\\2")
|
|
115
|
+
else
|
|
116
|
+
content.sub!(/^(\s*end\s*\z)/m, " resources :#{@resource_name}#{tables_arg}\n\\1")
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
File.write(routes_file, content)
|
|
120
|
+
puts " update #{routes_file}"
|
|
121
|
+
|
|
122
|
+
# Also update route manifest
|
|
123
|
+
inject_route_manifest
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def inject_route_manifest
|
|
127
|
+
manifest_file = "lambda/lib/routes/#{@app_name}_routes.rb"
|
|
128
|
+
return unless File.exist?(manifest_file)
|
|
129
|
+
|
|
130
|
+
id_param = "#{@singular_name}_id"
|
|
131
|
+
|
|
132
|
+
new_routes = [
|
|
133
|
+
"{ verb: 'GET', path: '/#{@resource_name}', controller: '#{@resource_name}', action: 'index' }",
|
|
134
|
+
"{ verb: 'POST', path: '/#{@resource_name}', controller: '#{@resource_name}', action: 'create' }",
|
|
135
|
+
"{ verb: 'GET', path: '/#{@resource_name}/{#{id_param}}', controller: '#{@resource_name}', action: 'show' }",
|
|
136
|
+
"{ verb: 'PUT', path: '/#{@resource_name}/{#{id_param}}', " \
|
|
137
|
+
"controller: '#{@resource_name}', action: 'update' }",
|
|
138
|
+
"{ verb: 'DELETE', path: '/#{@resource_name}/{#{id_param}}', " \
|
|
139
|
+
"controller: '#{@resource_name}', action: 'destroy' }"
|
|
140
|
+
]
|
|
141
|
+
|
|
142
|
+
existing_content = File.read(manifest_file)
|
|
143
|
+
constant = @app_name.upcase
|
|
144
|
+
|
|
145
|
+
# Extract existing route entries (preserve routes from other resources)
|
|
146
|
+
existing_routes = existing_content.scan(/\{ verb: .+? \}/)
|
|
147
|
+
|
|
148
|
+
# Merge: replace routes for this resource, keep everything else
|
|
149
|
+
other_routes = existing_routes.reject { |r| r.include?("controller: '#{@resource_name}'") }
|
|
150
|
+
all_routes = other_routes + new_routes
|
|
151
|
+
route_lines = all_routes.map { |r| " #{r}" }.join(",\n")
|
|
152
|
+
|
|
153
|
+
content = <<~RUBY
|
|
154
|
+
# frozen_string_literal: true
|
|
155
|
+
|
|
156
|
+
module Routes
|
|
157
|
+
#{constant} = [
|
|
158
|
+
#{route_lines}
|
|
159
|
+
].freeze
|
|
160
|
+
end
|
|
161
|
+
RUBY
|
|
162
|
+
|
|
163
|
+
File.write(manifest_file, content)
|
|
164
|
+
puts " update #{manifest_file}"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def inject_schema
|
|
168
|
+
schema_file = 'infrastructure/schema.tf.rb'
|
|
169
|
+
return unless File.exist?(schema_file)
|
|
170
|
+
|
|
171
|
+
content = File.read(schema_file)
|
|
172
|
+
|
|
173
|
+
field_lines = @fields.map { |f| " field :#{f[:name]}, type: :#{f[:type]}" }
|
|
174
|
+
field_lines << ' field :created_at, type: :string'
|
|
175
|
+
field_lines << ' field :updated_at, type: :string'
|
|
176
|
+
|
|
177
|
+
schema_block = " model :#{@singular_name} do\n#{field_lines.join("\n")}\n end\n"
|
|
178
|
+
|
|
179
|
+
# Replace commented-out block or insert before final end
|
|
180
|
+
if content.match?(/^\s*#\s*model :/)
|
|
181
|
+
content.gsub!(/^\s*#[^\n]*\n/, '')
|
|
182
|
+
content.sub!(/^(end\s*\z)/m, "#{schema_block}\\1")
|
|
183
|
+
else
|
|
184
|
+
content.sub!(/^(end\s*\z)/m, "\n#{schema_block}\\1")
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
File.write(schema_file, content)
|
|
188
|
+
puts " update #{schema_file}"
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def write_template(template_name, dest_path)
|
|
192
|
+
template_path = File.join(TEMPLATE_DIR, template_name)
|
|
193
|
+
FileUtils.mkdir_p(File.dirname(dest_path))
|
|
194
|
+
content = ERB.new(File.read(template_path), trim_mode: '-').result(binding)
|
|
195
|
+
File.write(dest_path, content)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def generate_views_if_frontend
|
|
199
|
+
return unless Dir.exist?('frontend/src')
|
|
200
|
+
return if @skip_views
|
|
201
|
+
|
|
202
|
+
Belt::CLI::ViewsCommand.new(@name, @fields).generate
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'erb'
|
|
5
|
+
|
|
6
|
+
module Belt
|
|
7
|
+
module CLI
|
|
8
|
+
class NewCommand
|
|
9
|
+
TEMPLATE_DIR = File.expand_path('../../templates/new_app', __dir__)
|
|
10
|
+
|
|
11
|
+
def self.run(args)
|
|
12
|
+
app_name = nil
|
|
13
|
+
frontend = nil
|
|
14
|
+
|
|
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
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
if app_name.nil? || app_name.empty?
|
|
28
|
+
puts 'Usage: belt new <app_name> [--frontend react|vue|svelte]'
|
|
29
|
+
exit 1
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
new(app_name, frontend: frontend).generate
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def initialize(app_name, frontend: nil)
|
|
36
|
+
@app_name = app_name.gsub(/[^a-z0-9_-]/i, '_').downcase
|
|
37
|
+
@module_name = @app_name.split(/[-_]/).map(&:capitalize).join
|
|
38
|
+
@frontend = frontend
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def generate
|
|
42
|
+
if Dir.exist?(@app_name)
|
|
43
|
+
puts "Directory '#{@app_name}' already exists."
|
|
44
|
+
exit 1
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
puts "Creating new Belt application: #{@app_name}"
|
|
48
|
+
create_structure
|
|
49
|
+
generate_frontend if @frontend
|
|
50
|
+
init_git
|
|
51
|
+
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}/"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def create_structure
|
|
64
|
+
directories.each { |dir| create_dir(dir) }
|
|
65
|
+
files.each { |src, dest| create_file(src, dest) }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def directories
|
|
69
|
+
%W[
|
|
70
|
+
#{@app_name}/lambda/controllers/#{@app_name}
|
|
71
|
+
#{@app_name}/lambda/models
|
|
72
|
+
#{@app_name}/lambda/models/concerns
|
|
73
|
+
#{@app_name}/lambda/lib/routes
|
|
74
|
+
#{@app_name}/lambda/spec
|
|
75
|
+
#{@app_name}/infrastructure
|
|
76
|
+
]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def files
|
|
80
|
+
{
|
|
81
|
+
'Gemfile.erb' => "#{@app_name}/Gemfile",
|
|
82
|
+
'Rakefile.erb' => "#{@app_name}/Rakefile",
|
|
83
|
+
'lambda/Gemfile.erb' => "#{@app_name}/lambda/Gemfile",
|
|
84
|
+
'lambda/api.rb.erb' => "#{@app_name}/lambda/#{@app_name}.rb",
|
|
85
|
+
'lambda/models/application_record.rb.erb' => "#{@app_name}/lambda/models/application_record.rb",
|
|
86
|
+
'lambda/models/concerns/timestampable.rb.erb' => "#{@app_name}/lambda/models/concerns/timestampable.rb",
|
|
87
|
+
'lambda/controllers/application_controller.rb.erb' =>
|
|
88
|
+
"#{@app_name}/lambda/controllers/#{@app_name}/application_controller.rb",
|
|
89
|
+
'lambda/lib/routes/routes.rb.erb' => "#{@app_name}/lambda/lib/routes/#{@app_name}_routes.rb",
|
|
90
|
+
'infrastructure/routes.tf.rb.erb' => "#{@app_name}/infrastructure/routes.tf.rb",
|
|
91
|
+
'infrastructure/schema.tf.rb.erb' => "#{@app_name}/infrastructure/schema.tf.rb",
|
|
92
|
+
'README.md.erb' => "#{@app_name}/README.md",
|
|
93
|
+
'AGENTS.md.erb' => "#{@app_name}/AGENTS.md",
|
|
94
|
+
'gitignore.erb' => "#{@app_name}/.gitignore"
|
|
95
|
+
}
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def create_dir(dir)
|
|
99
|
+
FileUtils.mkdir_p(dir)
|
|
100
|
+
puts " create #{dir}/"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def create_file(template_name, dest_path)
|
|
104
|
+
template_path = File.join(TEMPLATE_DIR, template_name)
|
|
105
|
+
content = ERB.new(File.read(template_path), trim_mode: '-').result(binding)
|
|
106
|
+
File.write(dest_path, content)
|
|
107
|
+
puts " create #{dest_path}"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def init_git
|
|
111
|
+
Dir.chdir(@app_name) do
|
|
112
|
+
system('git', 'init', '--quiet')
|
|
113
|
+
system('git', 'add', '.')
|
|
114
|
+
system('git', 'commit', '-m', 'Initial commit', '--quiet')
|
|
115
|
+
end
|
|
116
|
+
puts " init #{@app_name}/.git/"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def generate_frontend
|
|
120
|
+
Dir.chdir(@app_name) do
|
|
121
|
+
Belt::CLI::FrontendCommand.new(@frontend).generate
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Belt
|
|
4
|
+
module CLI
|
|
5
|
+
class RoutesCommand
|
|
6
|
+
# Extracts route controller/action inference logic from RoutesCommand.
|
|
7
|
+
module RouteInference
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def infer_controller(route, gateway)
|
|
11
|
+
return route.controller.to_s if route.controller
|
|
12
|
+
|
|
13
|
+
segments = route.path.split('/').reject(&:empty?)
|
|
14
|
+
non_param = segments.reject { |s| s.start_with?(':', '{') }
|
|
15
|
+
return gateway.name if non_param.empty?
|
|
16
|
+
|
|
17
|
+
return non_param.map { |s| s.gsub('-', '_') }.join('/') if route.resource? && nested_resource?(segments)
|
|
18
|
+
|
|
19
|
+
if route.resource?
|
|
20
|
+
non_param.first.gsub('-', '_')
|
|
21
|
+
elsif non_param.length == 1 && segments.length == 1
|
|
22
|
+
route.lambda.to_s == gateway.name.to_s ? gateway.name.to_s : route.lambda.to_s
|
|
23
|
+
else
|
|
24
|
+
non_param.first.gsub('-', '_')
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def infer_action(route, _gateway)
|
|
29
|
+
return route.action.to_s if route.action
|
|
30
|
+
|
|
31
|
+
segments = route.path.split('/').reject(&:empty?)
|
|
32
|
+
verb = route.method
|
|
33
|
+
|
|
34
|
+
if route.singular_resource?
|
|
35
|
+
infer_singular_resource_action(verb)
|
|
36
|
+
elsif route.plural_resource?
|
|
37
|
+
infer_plural_resource_action(verb, segments)
|
|
38
|
+
else
|
|
39
|
+
infer_plain_action(verb, segments)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def infer_singular_resource_action(verb)
|
|
44
|
+
case verb
|
|
45
|
+
when 'GET' then 'show'
|
|
46
|
+
when 'PUT', 'PATCH' then 'update'
|
|
47
|
+
when 'DELETE' then 'destroy'
|
|
48
|
+
when 'POST' then 'create'
|
|
49
|
+
else 'show'
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def infer_plural_resource_action(verb, segments)
|
|
54
|
+
has_id = segments.any? { |s| s.start_with?(':', '{') }
|
|
55
|
+
last_is_param = segments.last&.start_with?(':', '{')
|
|
56
|
+
|
|
57
|
+
if nested_resource?(segments)
|
|
58
|
+
child_idx = segments.rindex { |s| !s.start_with?(':', '{') }
|
|
59
|
+
has_child_id = child_idx && segments[(child_idx + 1)..]&.any? { |s| s.start_with?(':', '{') }
|
|
60
|
+
restful_action(verb, has_child_id || false)
|
|
61
|
+
else
|
|
62
|
+
restful_action(verb, has_id && last_is_param)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def infer_plain_action(verb, segments)
|
|
67
|
+
non_param = segments.reject { |s| s.start_with?(':', '{') }
|
|
68
|
+
has_id = segments.any? { |s| s.start_with?(':', '{') }
|
|
69
|
+
last_is_param = segments.last&.start_with?(':', '{')
|
|
70
|
+
|
|
71
|
+
if non_param.length <= 1 && !has_id
|
|
72
|
+
non_param.first&.gsub('-', '_') || 'index'
|
|
73
|
+
elsif non_param.length > 1
|
|
74
|
+
non_param.last.gsub('-', '_')
|
|
75
|
+
else
|
|
76
|
+
restful_action(verb, has_id && last_is_param)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def nested_resource?(segments)
|
|
81
|
+
segments.length >= 3 &&
|
|
82
|
+
!segments[0].start_with?(':', '{') &&
|
|
83
|
+
segments[1]&.start_with?(':', '{') &&
|
|
84
|
+
!segments[2]&.start_with?(':', '{')
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def restful_action(verb, is_member)
|
|
88
|
+
case [verb, is_member]
|
|
89
|
+
when ['GET', false] then 'index'
|
|
90
|
+
when ['GET', true] then 'show'
|
|
91
|
+
when ['POST', false] then 'create'
|
|
92
|
+
when ['PUT', true], ['PATCH', true] then 'update'
|
|
93
|
+
when ['DELETE', true] then 'destroy'
|
|
94
|
+
else 'index'
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Belt
|
|
4
|
+
module CLI
|
|
5
|
+
class RoutesCommand
|
|
6
|
+
# Extracts schema model loading logic from RoutesCommand.
|
|
7
|
+
module SchemaLoader
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def load_schema_models(routes_file)
|
|
11
|
+
schema_file = resolve_schema_file(routes_file)
|
|
12
|
+
return [] unless schema_file && File.exist?(schema_file)
|
|
13
|
+
|
|
14
|
+
Belt.instance_variable_set(:@application, nil)
|
|
15
|
+
begin
|
|
16
|
+
eval(File.read(schema_file), binding, schema_file) # rubocop:disable Security/Eval
|
|
17
|
+
rescue StandardError => e
|
|
18
|
+
warn "Warning: Failed to load schema file #{schema_file}: #{e.message}"
|
|
19
|
+
return []
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
schema = Belt.application.schema.to_h
|
|
23
|
+
build_models_from_schema(schema)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def resolve_schema_file(routes_file)
|
|
27
|
+
schema_file = @options[:schema_file]
|
|
28
|
+
unless schema_file
|
|
29
|
+
routes_dir = File.dirname(File.expand_path(routes_file))
|
|
30
|
+
schema_file = File.join(routes_dir, 'schema.tf.rb')
|
|
31
|
+
end
|
|
32
|
+
schema_file
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def build_models_from_schema(schema)
|
|
36
|
+
models = []
|
|
37
|
+
|
|
38
|
+
(schema[:request_models] || {}).each_value do |model|
|
|
39
|
+
models << {
|
|
40
|
+
name: model[:name],
|
|
41
|
+
kind: 'request',
|
|
42
|
+
description: "Request model: #{model[:name]}",
|
|
43
|
+
properties: stringify_properties(model[:properties] || {}),
|
|
44
|
+
required: (model[:required] || []).map(&:to_s)
|
|
45
|
+
}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
(schema[:response_models] || {}).each_value do |model|
|
|
49
|
+
(model[:contexts] || {}).each do |ctx_name, ctx|
|
|
50
|
+
models << {
|
|
51
|
+
name: "#{model[:name]}_#{ctx_name}_response",
|
|
52
|
+
kind: 'response',
|
|
53
|
+
description: "Response model: #{model[:name]} (#{ctx_name} context)",
|
|
54
|
+
properties: stringify_properties(ctx[:properties] || {}),
|
|
55
|
+
required: []
|
|
56
|
+
}
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
models
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def stringify_properties(properties)
|
|
64
|
+
properties.each_with_object({}) do |(key, value), hash|
|
|
65
|
+
hash[key.to_s] = value.transform_keys(&:to_s)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|