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.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +29 -1
  4. data/README.md +150 -51
  5. data/exe/belt +6 -0
  6. data/lib/belt/action_router.rb +7 -1
  7. data/lib/belt/cli/app_detection.rb +16 -0
  8. data/lib/belt/cli/bucket_security.rb +122 -0
  9. data/lib/belt/cli/env_resolver.rb +15 -0
  10. data/lib/belt/cli/environment_command.rb +77 -0
  11. data/lib/belt/cli/frontend_command.rb +85 -0
  12. data/lib/belt/cli/frontend_deploy_command.rb +125 -0
  13. data/lib/belt/cli/frontend_setup_command.rb +64 -0
  14. data/lib/belt/cli/generate_command.rb +206 -0
  15. data/lib/belt/cli/new_command.rb +126 -0
  16. data/lib/belt/cli/routes_command/route_inference.rb +100 -0
  17. data/lib/belt/cli/routes_command/schema_loader.rb +71 -0
  18. data/lib/belt/cli/routes_command.rb +307 -0
  19. data/lib/belt/cli/setup_command.rb +261 -0
  20. data/lib/belt/cli/tables_command.rb +138 -0
  21. data/lib/belt/cli/tasks_command.rb +110 -0
  22. data/lib/belt/cli/terraform_command.rb +77 -0
  23. data/lib/belt/cli/views_command.rb +134 -0
  24. data/lib/belt/cli.rb +117 -0
  25. data/lib/belt/lambda_handler.rb +16 -0
  26. data/lib/belt/root.rb +26 -0
  27. data/lib/belt/route_dsl.rb +605 -0
  28. data/lib/belt/table_inference.rb +71 -0
  29. data/lib/belt/version.rb +1 -1
  30. data/lib/belt.rb +1 -0
  31. data/lib/templates/environment/backend.tf.erb +8 -0
  32. data/lib/templates/environment/main.tf.erb +42 -0
  33. data/lib/templates/environment/terraform.tfvars.erb +1 -0
  34. data/lib/templates/environment/variables.tf.erb +16 -0
  35. data/lib/templates/frontend/react/index.html.erb +12 -0
  36. data/lib/templates/frontend/react/package.json.erb +20 -0
  37. data/lib/templates/frontend/react/src/App.jsx +14 -0
  38. data/lib/templates/frontend/react/src/index.css +10 -0
  39. data/lib/templates/frontend/react/src/lib/apiClient.js.erb +19 -0
  40. data/lib/templates/frontend/react/src/main.jsx +10 -0
  41. data/lib/templates/frontend/react/src/pages/Home.jsx.erb +10 -0
  42. data/lib/templates/frontend/react/vite.config.js +8 -0
  43. data/lib/templates/frontend_infra/frontend.tf.erb +159 -0
  44. data/lib/templates/generate/controller.rb.erb +59 -0
  45. data/lib/templates/generate/model.rb.erb +20 -0
  46. data/lib/templates/new_app/AGENTS.md.erb +130 -0
  47. data/lib/templates/new_app/Gemfile.erb +5 -0
  48. data/lib/templates/new_app/README.md.erb +25 -0
  49. data/lib/templates/new_app/Rakefile.erb +12 -0
  50. data/lib/templates/new_app/gitignore.erb +14 -0
  51. data/lib/templates/new_app/infrastructure/routes.tf.rb.erb +5 -0
  52. data/lib/templates/new_app/infrastructure/schema.tf.rb.erb +9 -0
  53. data/lib/templates/new_app/lambda/Gemfile.erb +7 -0
  54. data/lib/templates/new_app/lambda/api.rb.erb +22 -0
  55. data/lib/templates/new_app/lambda/controllers/application_controller.rb.erb +6 -0
  56. data/lib/templates/new_app/lambda/lib/routes/routes.rb.erb +11 -0
  57. data/lib/templates/new_app/lambda/models/application_record.rb.erb +6 -0
  58. data/lib/templates/new_app/lambda/models/concerns/timestampable.rb.erb +23 -0
  59. data/lib/templates/views/Edit.jsx.erb +38 -0
  60. data/lib/templates/views/Form.jsx.erb +34 -0
  61. data/lib/templates/views/Index.jsx.erb +39 -0
  62. data/lib/templates/views/New.jsx.erb +26 -0
  63. data/lib/templates/views/Show.jsx.erb +46 -0
  64. data.tar.gz.sig +0 -0
  65. metadata +73 -3
  66. metadata.gz.sig +0 -0
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'optparse'
5
+
6
+ module Belt
7
+ module CLI
8
+ class TasksCommand
9
+ def self.run(args)
10
+ new(args).run
11
+ end
12
+
13
+ # Check if a given command name looks like a rake task that exists
14
+ def self.rake_task?(name)
15
+ return false unless name.include?(':') || name.match?(/\A[a-z_]+\z/)
16
+ return false unless File.exist?('Rakefile') || File.exist?('rakefile') || File.exist?('Rakefile.rb')
17
+
18
+ # Only treat colon-namespaced commands as potential rake tasks to avoid
19
+ # ambiguity with belt's own commands
20
+ name.include?(':')
21
+ end
22
+
23
+ # Invoke a specific rake task by name
24
+ def self.invoke(task_name, args)
25
+ unless File.exist?('Rakefile') || File.exist?('rakefile') || File.exist?('Rakefile.rb')
26
+ abort "Error: No Rakefile found. Cannot run task '#{task_name}'."
27
+ end
28
+
29
+ cmd = ['bundle', 'exec', 'rake', task_name] + args
30
+ exec(*cmd)
31
+ end
32
+
33
+ def initialize(args)
34
+ @options = {}
35
+ parse_options(args)
36
+ end
37
+
38
+ def run
39
+ abort 'Error: No Rakefile found. Add a Rakefile to your project to discover tasks.' unless rakefile_available?
40
+
41
+ tasks = load_tasks
42
+ tasks = apply_grep(tasks) if @options[:grep]
43
+
44
+ if tasks.empty?
45
+ puts 'No rake tasks found.'
46
+ else
47
+ output_tasks(tasks)
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def parse_options(args)
54
+ OptionParser.new do |opts|
55
+ opts.banner = 'Usage: belt tasks [options]'
56
+
57
+ opts.on('-g', '--grep PATTERN', 'Filter tasks matching pattern') do |pattern|
58
+ @options[:grep] = pattern
59
+ end
60
+
61
+ opts.on('-a', '--all', 'Show all tasks (including those without descriptions)') do
62
+ @options[:all] = true
63
+ end
64
+
65
+ opts.on('-h', '--help', 'Show this help') do
66
+ puts opts
67
+ exit
68
+ end
69
+ end.parse!(args)
70
+ end
71
+
72
+ def rakefile_available?
73
+ File.exist?('Rakefile') || File.exist?('rakefile') || File.exist?('Rakefile.rb')
74
+ end
75
+
76
+ def load_tasks
77
+ cmd = @options[:all] ? %w[bundle exec rake -T -A] : %w[bundle exec rake -T]
78
+ output, status = Open3.capture2(*cmd, err: File::NULL)
79
+
80
+ abort 'Error: Failed to load rake tasks. Ensure `bundle install` has been run.' unless status.success?
81
+
82
+ parse_task_output(output)
83
+ end
84
+
85
+ def parse_task_output(output)
86
+ output.lines.filter_map do |line|
87
+ match = line.match(/^rake\s+(\S+)\s*#\s*(.*)$/)
88
+ next unless match
89
+
90
+ { name: match[1], description: match[2].strip }
91
+ end
92
+ end
93
+
94
+ def apply_grep(tasks)
95
+ pattern = Regexp.new(@options[:grep], Regexp::IGNORECASE)
96
+ tasks.select do |t|
97
+ t[:name].match?(pattern) || t[:description].match?(pattern)
98
+ end
99
+ end
100
+
101
+ def output_tasks(tasks)
102
+ name_w = [tasks.map { |t| t[:name].length }.max, 4].max
103
+
104
+ tasks.each do |t|
105
+ puts "belt #{t[:name].ljust(name_w)} # #{t[:description]}"
106
+ end
107
+ end
108
+ end
109
+ end
110
+ 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,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'version'
4
+ require_relative 'root'
5
+ require_relative 'cli/env_resolver'
6
+ require_relative 'cli/new_command'
7
+ require_relative 'cli/generate_command'
8
+ require_relative 'cli/frontend_command'
9
+ require_relative 'cli/frontend_setup_command'
10
+ require_relative 'cli/frontend_deploy_command'
11
+ require_relative 'cli/views_command'
12
+ require_relative 'cli/setup_command'
13
+ require_relative 'cli/terraform_command'
14
+ require_relative 'cli/routes_command'
15
+ require_relative 'cli/tasks_command'
16
+
17
+ module Belt
18
+ module CLI
19
+ COMMANDS_DEFINITION = {
20
+ 'new' => Belt::CLI::NewCommand,
21
+ %w[generate g] => Belt::CLI::GenerateCommand,
22
+ 'routes' => Belt::CLI::RoutesCommand,
23
+ %w[tasks --tasks -T] => Belt::CLI::TasksCommand,
24
+ 'setup' => Belt::CLI::SetupCommand,
25
+ 'deploy' => lambda { |args|
26
+ subcommand = args.shift
27
+ if subcommand == 'frontend'
28
+ Belt::CLI::FrontendDeployCommand.run(args)
29
+ else
30
+ puts 'Usage: belt deploy frontend <environment>'
31
+ exit 1
32
+ end
33
+ },
34
+ %w[version --version -v] => ->(_args) { puts "Belt #{Belt::VERSION}" }
35
+ }.freeze
36
+
37
+ COMMANDS = COMMANDS_DEFINITION.each_with_object({}) do |(keys, handler), hash|
38
+ Array(keys).each { |key| hash[key] = handler }
39
+ end.freeze
40
+
41
+ TERRAFORM_ACTIONS = Belt::CLI::TerraformCommand::ACTIONS
42
+
43
+ def self.start(args)
44
+ command = args.shift
45
+
46
+ if command.nil?
47
+ puts usage
48
+ exit 1
49
+ end
50
+
51
+ # Terraform shorthand: belt init wups, belt plan wups, belt apply wups
52
+ return Belt::CLI::TerraformCommand.run(command, args) if TERRAFORM_ACTIONS.include?(command)
53
+
54
+ handler = COMMANDS[command]
55
+
56
+ # If no built-in command matched, try running it as a rake task
57
+ if handler.nil?
58
+ return Belt::CLI::TasksCommand.invoke(command, args) if Belt::CLI::TasksCommand.rake_task?(command)
59
+
60
+ puts "Unknown command: #{command}\n\n#{usage}"
61
+ exit 1
62
+ end
63
+
64
+ if handler.is_a?(Proc)
65
+ handler.call(args)
66
+ else
67
+ handler.run(args)
68
+ end
69
+ end
70
+
71
+ def self.usage
72
+ <<~USAGE
73
+ Usage: belt <command> [options]
74
+
75
+ Commands:
76
+ new <app_name> [--frontend react] Create a new Belt application
77
+ generate <resource|model|controller> <name> Generate components
78
+ generate frontend <react|vue|svelte> Scaffold a frontend app
79
+ generate views <resource> [fields...] Generate React pages for REST actions
80
+ generate environment <name> Create a new environment
81
+ routes [-g PATTERN] [-f json] Show route definitions
82
+ tasks [-g PATTERN] [-a] List available rake tasks
83
+ -T [-g PATTERN] [-a] Alias for tasks
84
+ setup state Create/select S3 state bucket
85
+ setup tables <env> Generate DynamoDB tables from schema
86
+ setup frontend <env> Generate S3 + CloudFront infrastructure
87
+ deploy frontend <env> Build and deploy frontend to AWS
88
+ init <env> terraform init for environment
89
+ plan <env> terraform plan for environment
90
+ apply <env> terraform apply for environment
91
+ destroy <env> terraform destroy for environment
92
+ output <env> terraform output for environment
93
+ --version Show Belt version
94
+
95
+ Rake Tasks:
96
+ Any rake task from your Gemfile dependencies can be run directly:
97
+ belt lambda:build_layer Run a rake task by name
98
+
99
+ Environment:
100
+ Set BELT_ENV to skip the <env> argument:
101
+ export BELT_ENV=wups
102
+ belt apply # uses BELT_ENV
103
+ belt apply dev01 # explicit arg wins
104
+
105
+ Examples:
106
+ belt new blog --frontend react
107
+ belt generate resource post title:string content:text status:string
108
+ belt generate frontend react
109
+ belt setup frontend wups
110
+ belt deploy frontend wups
111
+ belt apply wups
112
+ belt tasks # list all rake tasks
113
+ belt lambda:build_layer # run a rake task directly
114
+ USAGE
115
+ end
116
+ end
117
+ end
@@ -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/root.rb ADDED
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Belt
4
+ def self.root
5
+ @root ||= detect_root
6
+ end
7
+
8
+ def self.root=(path)
9
+ @root = path
10
+ end
11
+
12
+ def self.detect_root
13
+ dir = Dir.pwd
14
+ loop do
15
+ return dir if File.exist?(File.join(dir, 'infrastructure/routes.tf.rb'))
16
+
17
+ parent = File.dirname(dir)
18
+ break if parent == dir
19
+
20
+ dir = parent
21
+ end
22
+ Dir.pwd
23
+ end
24
+
25
+ private_class_method :detect_root
26
+ end