belt 0.0.7 → 0.1.0
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/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 +125 -0
- data/lib/belt/cli/setup_command.rb +261 -0
- data/lib/belt/cli/tables_command.rb +138 -0
- data/lib/belt/cli/terraform_command.rb +77 -0
- data/lib/belt/cli/views_command.rb +134 -0
- data/lib/belt/cli.rb +98 -0
- data/lib/belt/lambda_handler.rb +16 -0
- data/lib/belt/version.rb +1 -1
- 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 +6 -0
- data/lib/templates/new_app/README.md.erb +25 -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 +51 -3
- metadata.gz.sig +0 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'app_detection'
|
|
4
|
+
require_relative 'env_resolver'
|
|
5
|
+
|
|
6
|
+
module Belt
|
|
7
|
+
module CLI
|
|
8
|
+
class TablesCommand
|
|
9
|
+
SCHEMA_FILE = 'infrastructure/schema.tf.rb'
|
|
10
|
+
|
|
11
|
+
include AppDetection
|
|
12
|
+
|
|
13
|
+
def self.run(args)
|
|
14
|
+
env = EnvResolver.resolve(args)
|
|
15
|
+
|
|
16
|
+
if env.nil?
|
|
17
|
+
puts 'Usage: belt setup tables <environment>'
|
|
18
|
+
puts "\nReads schema.tf.rb and generates dynamodb.tf in the environment directory."
|
|
19
|
+
puts 'You can also set BELT_ENV to skip the environment argument.'
|
|
20
|
+
puts "\nExamples:"
|
|
21
|
+
puts ' belt setup tables wups'
|
|
22
|
+
puts ' belt setup tables dev01'
|
|
23
|
+
puts ' BELT_ENV=wups belt setup tables'
|
|
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
|
+
models = parse_schema
|
|
39
|
+
if models.empty?
|
|
40
|
+
puts "No models found in #{SCHEMA_FILE}"
|
|
41
|
+
exit 0
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
generate_dynamodb_tf(models)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def validate!
|
|
50
|
+
abort "Error: #{SCHEMA_FILE} not found. Run `belt generate resource` first." unless File.exist?(SCHEMA_FILE)
|
|
51
|
+
return if Dir.exist?(@env_dir)
|
|
52
|
+
|
|
53
|
+
abort "Error: Environment '#{@env}' not found at #{@env_dir}/.\n" \
|
|
54
|
+
"Create it with: belt generate environment #{@env}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def parse_schema
|
|
58
|
+
parser = SchemaParser.new
|
|
59
|
+
schema_content = File.read(SCHEMA_FILE)
|
|
60
|
+
|
|
61
|
+
# Replace DSL wrapper with direct parser call
|
|
62
|
+
# TerraDispatch.schema.draw do ... end → parser.instance_eval do ... end
|
|
63
|
+
inner = schema_content.sub(/\ATerraDispatch\.schema\.draw do\n?/, '').sub(/\nend\s*\z/, '')
|
|
64
|
+
parser.instance_eval(inner, SCHEMA_FILE)
|
|
65
|
+
parser.models
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def generate_dynamodb_tf(models)
|
|
69
|
+
dest = File.join(@env_dir, 'dynamodb.tf')
|
|
70
|
+
content = render_dynamodb(models)
|
|
71
|
+
File.write(dest, content)
|
|
72
|
+
puts " create #{dest}"
|
|
73
|
+
puts "\n✓ Generated DynamoDB tables for #{models.size} model(s):"
|
|
74
|
+
models.each { |m| puts " • #{table_name(m[:name])}" }
|
|
75
|
+
puts "\nRun `belt apply #{@env}` to create them."
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def render_dynamodb(models)
|
|
79
|
+
blocks = models.map { |m| render_table(m) }
|
|
80
|
+
"# Auto-generated by Belt from schema.tf.rb\n" \
|
|
81
|
+
"# Do not edit manually — re-run `belt setup tables #{@env}`\n\n#{blocks.join("\n\n")}\n"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def render_table(model)
|
|
85
|
+
name = table_name(model[:name])
|
|
86
|
+
<<~HCL
|
|
87
|
+
resource "aws_dynamodb_table" "#{model[:name]}s" {
|
|
88
|
+
name = "#{name}"
|
|
89
|
+
billing_mode = "PAY_PER_REQUEST"
|
|
90
|
+
hash_key = "id"
|
|
91
|
+
|
|
92
|
+
attribute {
|
|
93
|
+
name = "id"
|
|
94
|
+
type = "S"
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
tags = {
|
|
98
|
+
Name = "#{name}"
|
|
99
|
+
Environment = var.environment
|
|
100
|
+
ManagedBy = "Terraform"
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
HCL
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def table_name(model_name)
|
|
107
|
+
"#{@app_name}-#{@env}-#{model_name}s"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Minimal DSL parser for schema.tf.rb
|
|
111
|
+
class SchemaParser
|
|
112
|
+
attr_reader :models
|
|
113
|
+
|
|
114
|
+
def initialize
|
|
115
|
+
@models = []
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def model(name, &block)
|
|
119
|
+
model_def = ModelParser.new
|
|
120
|
+
model_def.instance_eval(&block) if block
|
|
121
|
+
@models << { name: name.to_s, fields: model_def.fields }
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
class ModelParser
|
|
126
|
+
attr_reader :fields
|
|
127
|
+
|
|
128
|
+
def initialize
|
|
129
|
+
@fields = []
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def field(name, **opts)
|
|
133
|
+
@fields << { name: name.to_s, type: opts[:type]&.to_s || 'string' }
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'env_resolver'
|
|
4
|
+
|
|
5
|
+
module Belt
|
|
6
|
+
module CLI
|
|
7
|
+
class TerraformCommand
|
|
8
|
+
ACTIONS = %w[init plan apply destroy output].freeze
|
|
9
|
+
|
|
10
|
+
def self.run(action, args)
|
|
11
|
+
env = EnvResolver.resolve(args)
|
|
12
|
+
|
|
13
|
+
if env.nil?
|
|
14
|
+
puts "Usage: belt #{action} <environment> [terraform flags...]"
|
|
15
|
+
puts "\nYou can also set BELT_ENV to skip the environment argument."
|
|
16
|
+
puts "\nExamples:"
|
|
17
|
+
puts " belt #{action} wups"
|
|
18
|
+
puts " belt #{action} dev01"
|
|
19
|
+
puts " BELT_ENV=wups belt #{action}"
|
|
20
|
+
puts ' belt plan staging -target=module.lambda'
|
|
21
|
+
puts "\nAvailable environments:"
|
|
22
|
+
list_environments.each { |e| puts " #{e}" }
|
|
23
|
+
exit 1
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
new(action, env, args).run
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.list_environments
|
|
30
|
+
infra_dir = find_infrastructure_dir
|
|
31
|
+
return [] unless infra_dir
|
|
32
|
+
|
|
33
|
+
Dir.children(infra_dir)
|
|
34
|
+
.select { |d| File.directory?(File.join(infra_dir, d)) }
|
|
35
|
+
.reject { |d| d.start_with?('.') || d == 'modules' }
|
|
36
|
+
.sort
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.find_infrastructure_dir
|
|
40
|
+
%w[infrastructure infra].each do |dir|
|
|
41
|
+
return dir if Dir.exist?(dir)
|
|
42
|
+
end
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def initialize(action, env, extra_args)
|
|
47
|
+
@action = action
|
|
48
|
+
@env = env
|
|
49
|
+
@extra_args = extra_args
|
|
50
|
+
@infra_dir = self.class.find_infrastructure_dir
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def run
|
|
54
|
+
validate!
|
|
55
|
+
env_dir = File.join(@infra_dir, @env)
|
|
56
|
+
args = ['terraform', @action, *@extra_args]
|
|
57
|
+
puts "belt → #{args.join(' ')} (in #{env_dir}/)"
|
|
58
|
+
Dir.chdir(env_dir) { exec(*args) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def validate!
|
|
64
|
+
unless @infra_dir
|
|
65
|
+
abort "Error: No infrastructure/ directory found. Run `belt generate environment #{@env}` first."
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
env_dir = File.join(@infra_dir, @env)
|
|
69
|
+
return if Dir.exist?(env_dir)
|
|
70
|
+
|
|
71
|
+
abort "Error: Environment '#{@env}' not found at #{env_dir}/.\n\n" \
|
|
72
|
+
"Available environments:\n#{self.class.list_environments.map { |e| " #{e}" }.join("\n")}\n\n" \
|
|
73
|
+
"Create it with: belt generate environment #{@env}"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'erb'
|
|
5
|
+
|
|
6
|
+
module Belt
|
|
7
|
+
module CLI
|
|
8
|
+
class ViewsCommand
|
|
9
|
+
TEMPLATE_DIR = File.expand_path('../../templates/views', __dir__)
|
|
10
|
+
|
|
11
|
+
def self.run(args)
|
|
12
|
+
name = args.shift
|
|
13
|
+
if name.nil? || name.empty?
|
|
14
|
+
puts 'Usage: belt generate views <resource> [field:type ...]'
|
|
15
|
+
puts "\nGenerates React pages for all REST actions (index, show, new, edit)."
|
|
16
|
+
puts "\nExamples:"
|
|
17
|
+
puts ' belt generate views post title:string content:text status:string'
|
|
18
|
+
puts ' belt generate views comment body:text author:string'
|
|
19
|
+
exit 1
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
fields = args.map do |arg|
|
|
23
|
+
n, t = arg.split(':', 2)
|
|
24
|
+
{ name: n, type: t || 'string' }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# If no fields provided, try to read from schema.tf.rb
|
|
28
|
+
fields = read_schema_fields(name) if fields.empty?
|
|
29
|
+
|
|
30
|
+
new(name, fields).generate
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.read_schema_fields(name)
|
|
34
|
+
schema_file = 'infrastructure/schema.tf.rb'
|
|
35
|
+
return [] unless File.exist?(schema_file)
|
|
36
|
+
|
|
37
|
+
content = File.read(schema_file)
|
|
38
|
+
singular = name.end_with?('s') ? name.chomp('s') : name
|
|
39
|
+
|
|
40
|
+
# Extract fields from model block
|
|
41
|
+
if content =~ /model :#{singular} do\n(.*?)\n\s*end/m
|
|
42
|
+
::Regexp.last_match(1).scan(/field :(\w+), type: :(\w+)/).except('created_at', 'updated_at')
|
|
43
|
+
.map do |n, t|
|
|
44
|
+
{
|
|
45
|
+
name: n, type: t
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
else
|
|
49
|
+
[]
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def initialize(name, fields)
|
|
54
|
+
@name = name.downcase.gsub(/[^a-z0-9_]/, '_')
|
|
55
|
+
@fields = fields
|
|
56
|
+
@resource_name = @name.end_with?('s') ? @name : "#{@name}s"
|
|
57
|
+
@singular_name = @name.end_with?('s') ? @name.chomp('s') : @name
|
|
58
|
+
@class_name = @singular_name.split('_').map(&:capitalize).join
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def generate
|
|
62
|
+
unless Dir.exist?('frontend/src')
|
|
63
|
+
puts '✗ No frontend/ directory found. Run `belt generate frontend react` first.'
|
|
64
|
+
exit 1
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
pages_dir = "frontend/src/pages/#{@resource_name}"
|
|
68
|
+
FileUtils.mkdir_p(pages_dir)
|
|
69
|
+
|
|
70
|
+
write_template('Index.jsx.erb', "#{pages_dir}/#{@class_name}sIndex.jsx")
|
|
71
|
+
write_template('Show.jsx.erb', "#{pages_dir}/#{@class_name}Show.jsx")
|
|
72
|
+
write_template('New.jsx.erb', "#{pages_dir}/#{@class_name}New.jsx")
|
|
73
|
+
write_template('Edit.jsx.erb', "#{pages_dir}/#{@class_name}Edit.jsx")
|
|
74
|
+
write_template('Form.jsx.erb', "#{pages_dir}/#{@class_name}Form.jsx")
|
|
75
|
+
|
|
76
|
+
inject_routes
|
|
77
|
+
|
|
78
|
+
puts "\n✓ Views for '#{@singular_name}' generated!"
|
|
79
|
+
puts "\nFiles created:"
|
|
80
|
+
puts " #{pages_dir}/#{@class_name}sIndex.jsx"
|
|
81
|
+
puts " #{pages_dir}/#{@class_name}Show.jsx"
|
|
82
|
+
puts " #{pages_dir}/#{@class_name}New.jsx"
|
|
83
|
+
puts " #{pages_dir}/#{@class_name}Edit.jsx"
|
|
84
|
+
puts " #{pages_dir}/#{@class_name}Form.jsx"
|
|
85
|
+
puts ' frontend/src/App.jsx (updated)'
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def write_template(template_name, dest_path)
|
|
91
|
+
template_path = File.join(TEMPLATE_DIR, template_name)
|
|
92
|
+
content = ERB.new(File.read(template_path), trim_mode: '-').result(binding)
|
|
93
|
+
File.write(dest_path, content)
|
|
94
|
+
puts " create #{dest_path}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def inject_routes
|
|
98
|
+
app_jsx = 'frontend/src/App.jsx'
|
|
99
|
+
return unless File.exist?(app_jsx)
|
|
100
|
+
|
|
101
|
+
content = File.read(app_jsx)
|
|
102
|
+
pages_dir = @resource_name
|
|
103
|
+
|
|
104
|
+
import_lines = [
|
|
105
|
+
"import #{@class_name}sIndex from './pages/#{pages_dir}/#{@class_name}sIndex'",
|
|
106
|
+
"import #{@class_name}Show from './pages/#{pages_dir}/#{@class_name}Show'",
|
|
107
|
+
"import #{@class_name}New from './pages/#{pages_dir}/#{@class_name}New'",
|
|
108
|
+
"import #{@class_name}Edit from './pages/#{pages_dir}/#{@class_name}Edit'"
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
route_lines = [
|
|
112
|
+
" <Route path=\"/#{@resource_name}\" element={<#{@class_name}sIndex />} />",
|
|
113
|
+
" <Route path=\"/#{@resource_name}/new\" element={<#{@class_name}New />} />",
|
|
114
|
+
" <Route path=\"/#{@resource_name}/:id\" element={<#{@class_name}Show />} />",
|
|
115
|
+
" <Route path=\"/#{@resource_name}/:id/edit\" element={<#{@class_name}Edit />} />"
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
# Add imports after last import line
|
|
119
|
+
last_import_idx = content.rindex(/^import .+$/)
|
|
120
|
+
if last_import_idx
|
|
121
|
+
end_of_line = content.index("\n", last_import_idx)
|
|
122
|
+
content.insert(end_of_line, "\n#{import_lines.join("\n")}")
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Add routes before closing </Routes> (no regex — avoids polynomial backtracking)
|
|
126
|
+
close_idx = content.index('</Routes>')
|
|
127
|
+
content.insert(close_idx, "#{route_lines.join("\n")}\n") if close_idx
|
|
128
|
+
|
|
129
|
+
File.write(app_jsx, content)
|
|
130
|
+
puts " update #{app_jsx}"
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
data/lib/belt/cli.rb
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'version'
|
|
4
|
+
require_relative 'cli/env_resolver'
|
|
5
|
+
require_relative 'cli/new_command'
|
|
6
|
+
require_relative 'cli/generate_command'
|
|
7
|
+
require_relative 'cli/frontend_command'
|
|
8
|
+
require_relative 'cli/frontend_setup_command'
|
|
9
|
+
require_relative 'cli/frontend_deploy_command'
|
|
10
|
+
require_relative 'cli/views_command'
|
|
11
|
+
require_relative 'cli/setup_command'
|
|
12
|
+
require_relative 'cli/terraform_command'
|
|
13
|
+
|
|
14
|
+
module Belt
|
|
15
|
+
module CLI
|
|
16
|
+
COMMANDS = {
|
|
17
|
+
'new' => Belt::CLI::NewCommand,
|
|
18
|
+
'generate' => Belt::CLI::GenerateCommand,
|
|
19
|
+
'g' => Belt::CLI::GenerateCommand,
|
|
20
|
+
'setup' => Belt::CLI::SetupCommand,
|
|
21
|
+
'deploy' => lambda { |args|
|
|
22
|
+
subcommand = args.shift
|
|
23
|
+
if subcommand == 'frontend'
|
|
24
|
+
Belt::CLI::FrontendDeployCommand.run(args)
|
|
25
|
+
else
|
|
26
|
+
puts 'Usage: belt deploy frontend <environment>'
|
|
27
|
+
exit 1
|
|
28
|
+
end
|
|
29
|
+
},
|
|
30
|
+
'--version' => ->(_args) { puts "Belt #{Belt::VERSION}" },
|
|
31
|
+
'-v' => ->(_args) { puts "Belt #{Belt::VERSION}" }
|
|
32
|
+
}.freeze
|
|
33
|
+
|
|
34
|
+
TERRAFORM_ACTIONS = Belt::CLI::TerraformCommand::ACTIONS
|
|
35
|
+
|
|
36
|
+
def self.start(args)
|
|
37
|
+
command = args.shift
|
|
38
|
+
|
|
39
|
+
if command.nil?
|
|
40
|
+
puts usage
|
|
41
|
+
exit 1
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Terraform shorthand: belt init wups, belt plan wups, belt apply wups
|
|
45
|
+
return Belt::CLI::TerraformCommand.run(command, args) if TERRAFORM_ACTIONS.include?(command)
|
|
46
|
+
|
|
47
|
+
handler = COMMANDS[command]
|
|
48
|
+
|
|
49
|
+
if handler.nil?
|
|
50
|
+
puts "Unknown command: #{command}\n\n#{usage}"
|
|
51
|
+
exit 1
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
if handler.is_a?(Proc)
|
|
55
|
+
handler.call(args)
|
|
56
|
+
else
|
|
57
|
+
handler.run(args)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def self.usage
|
|
62
|
+
<<~USAGE
|
|
63
|
+
Usage: belt <command> [options]
|
|
64
|
+
|
|
65
|
+
Commands:
|
|
66
|
+
new <app_name> [--frontend react] Create a new Belt application
|
|
67
|
+
generate <resource|model|controller> <name> Generate components
|
|
68
|
+
generate frontend <react|vue|svelte> Scaffold a frontend app
|
|
69
|
+
generate views <resource> [fields...] Generate React pages for REST actions
|
|
70
|
+
generate environment <name> Create a new environment
|
|
71
|
+
setup state Create/select S3 state bucket
|
|
72
|
+
setup tables <env> Generate DynamoDB tables from schema
|
|
73
|
+
setup frontend <env> Generate S3 + CloudFront infrastructure
|
|
74
|
+
deploy frontend <env> Build and deploy frontend to AWS
|
|
75
|
+
init <env> terraform init for environment
|
|
76
|
+
plan <env> terraform plan for environment
|
|
77
|
+
apply <env> terraform apply for environment
|
|
78
|
+
destroy <env> terraform destroy for environment
|
|
79
|
+
output <env> terraform output for environment
|
|
80
|
+
--version Show Belt version
|
|
81
|
+
|
|
82
|
+
Environment:
|
|
83
|
+
Set BELT_ENV to skip the <env> argument:
|
|
84
|
+
export BELT_ENV=wups
|
|
85
|
+
belt apply # uses BELT_ENV
|
|
86
|
+
belt apply dev01 # explicit arg wins
|
|
87
|
+
|
|
88
|
+
Examples:
|
|
89
|
+
belt new blog --frontend react
|
|
90
|
+
belt generate resource post title:string content:text status:string
|
|
91
|
+
belt generate frontend react
|
|
92
|
+
belt setup frontend wups
|
|
93
|
+
belt deploy frontend wups
|
|
94
|
+
belt apply wups
|
|
95
|
+
USAGE
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
data/lib/belt/lambda_handler.rb
CHANGED
|
@@ -25,6 +25,22 @@ module Belt
|
|
|
25
25
|
|
|
26
26
|
def self.included(base)
|
|
27
27
|
base.instance_variable_set(:@belt_lambda_handler_included, true)
|
|
28
|
+
|
|
29
|
+
# Skip auto-registration in test environments to avoid stale paths
|
|
30
|
+
return if ENV['BELT_ENV'] == 'test' || ENV['RACK_ENV'] == 'test' || defined?(RSpec)
|
|
31
|
+
|
|
32
|
+
# Auto-register controllers directory relative to the including file's location
|
|
33
|
+
caller_file = caller_locations(1, 1)&.first&.path
|
|
34
|
+
return unless caller_file
|
|
35
|
+
|
|
36
|
+
controllers_dir = File.join(File.dirname(caller_file), 'controllers')
|
|
37
|
+
return unless File.directory?(controllers_dir)
|
|
38
|
+
|
|
39
|
+
Belt.controller_paths << controllers_dir
|
|
40
|
+
Dir.children(controllers_dir).each do |child|
|
|
41
|
+
subdir = File.join(controllers_dir, child)
|
|
42
|
+
Belt.controller_paths << subdir if File.directory?(subdir)
|
|
43
|
+
end
|
|
28
44
|
end
|
|
29
45
|
|
|
30
46
|
# API Gateway Lambda handler.
|
data/lib/belt/version.rb
CHANGED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
terraform {
|
|
2
|
+
required_providers {
|
|
3
|
+
aws = {
|
|
4
|
+
source = "hashicorp/aws"
|
|
5
|
+
version = "~> 5.0"
|
|
6
|
+
}
|
|
7
|
+
random = {
|
|
8
|
+
source = "hashicorp/random"
|
|
9
|
+
version = "~> 3.0"
|
|
10
|
+
}
|
|
11
|
+
dispatcher = {
|
|
12
|
+
source = "terraform.local/stowzilla/dispatcher"
|
|
13
|
+
version = "99.0.0"
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
provider "aws" {
|
|
19
|
+
region = var.aws_region
|
|
20
|
+
|
|
21
|
+
default_tags {
|
|
22
|
+
tags = {
|
|
23
|
+
Project = "<%= @app_name.capitalize %>"
|
|
24
|
+
Environment = var.environment
|
|
25
|
+
ManagedBy = "Terraform"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
provider "dispatcher" {
|
|
31
|
+
environment = var.environment
|
|
32
|
+
aws_region = var.aws_region
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
resource "dispatcher" "main" {
|
|
36
|
+
source = "${path.module}/../routes.tf.rb"
|
|
37
|
+
app_name = var.app_name
|
|
38
|
+
lambda_source_dir = "${path.module}/../../lambda"
|
|
39
|
+
lambda_shared_dirs = ["controllers", "helpers", "lib", "models", "templates"]
|
|
40
|
+
frontend_urls = ["http://localhost:3000"]
|
|
41
|
+
friendly_errors = true
|
|
42
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
environment = "<%= @env_name %>"
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
variable "app_name" {
|
|
2
|
+
description = "Name of the application"
|
|
3
|
+
type = string
|
|
4
|
+
default = "<%= @app_name %>"
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
variable "environment" {
|
|
8
|
+
description = "Environment name"
|
|
9
|
+
type = string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
variable "aws_region" {
|
|
13
|
+
description = "AWS region"
|
|
14
|
+
type = string
|
|
15
|
+
default = "us-east-1"
|
|
16
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title><%= @module_name %></title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="root"></div>
|
|
10
|
+
<script type="module" src="/src/main.jsx"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "<%= @app_name %>",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "vite",
|
|
8
|
+
"build": "vite build",
|
|
9
|
+
"preview": "vite preview"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"react": "^18.3.1",
|
|
13
|
+
"react-dom": "^18.3.1",
|
|
14
|
+
"react-router-dom": "^6.28.0"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"@vitejs/plugin-react": "^4.3.4",
|
|
18
|
+
"vite": "^6.0.0"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { BrowserRouter, Routes, Route } from 'react-router-dom'
|
|
2
|
+
import Home from './pages/Home'
|
|
3
|
+
|
|
4
|
+
function App() {
|
|
5
|
+
return (
|
|
6
|
+
<BrowserRouter>
|
|
7
|
+
<Routes>
|
|
8
|
+
<Route path="/" element={<Home />} />
|
|
9
|
+
</Routes>
|
|
10
|
+
</BrowserRouter>
|
|
11
|
+
)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default App
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000'
|
|
2
|
+
|
|
3
|
+
export async function apiClient(path, options = {}) {
|
|
4
|
+
const { method = 'GET', body, headers = {} } = options
|
|
5
|
+
|
|
6
|
+
const config = {
|
|
7
|
+
method,
|
|
8
|
+
headers: { 'Content-Type': 'application/json', ...headers }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
if (body) config.body = JSON.stringify(body)
|
|
12
|
+
|
|
13
|
+
const response = await fetch(`${API_URL}${path}`, config)
|
|
14
|
+
const data = await response.json()
|
|
15
|
+
|
|
16
|
+
if (!response.ok) throw new Error(data.error || `Request failed: ${response.status}`)
|
|
17
|
+
|
|
18
|
+
return data
|
|
19
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
function Home() {
|
|
2
|
+
return (
|
|
3
|
+
<main style={{ padding: '2rem', maxWidth: '800px', margin: '0 auto' }}>
|
|
4
|
+
<h1><%= @module_name %></h1>
|
|
5
|
+
<p>Your Belt app is running. Edit <code>src/pages/Home.jsx</code> to get started.</p>
|
|
6
|
+
</main>
|
|
7
|
+
)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export default Home
|