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,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