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,307 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'optparse'
5
+ require 'fileutils'
6
+ require_relative '../route_dsl'
7
+ require_relative '../table_inference'
8
+ require_relative 'routes_command/schema_loader'
9
+ require_relative 'routes_command/route_inference'
10
+
11
+ module Belt
12
+ module CLI
13
+ class RoutesCommand
14
+ include SchemaLoader
15
+ include RouteInference
16
+
17
+ def self.run(args)
18
+ new(args).run
19
+ end
20
+
21
+ def initialize(args)
22
+ @options = {}
23
+ parse_options(args)
24
+ end
25
+
26
+ def run
27
+ routes_file = find_routes_file
28
+ abort 'Error: No routes file found. Expected infrastructure/routes.tf.rb' unless routes_file
29
+
30
+ dsl = load_routes(routes_file)
31
+ @table_inference = TableInference.new(@options[:tables_file])
32
+ routes = collect_routes(dsl)
33
+ routes = apply_grep(routes) if @options[:grep]
34
+
35
+ warn 'Warning: --output-dir has no effect without --namespace' if @options[:output_dir] && !@options[:namespace]
36
+
37
+ if @options[:namespace]
38
+ output_ruby(routes, @options[:namespace], routes_file)
39
+ elsif @options[:format] == 'json'
40
+ output = { routes: routes }
41
+ models = load_schema_models(routes_file)
42
+ output[:models] = models if models.any?
43
+ puts JSON.pretty_generate(output)
44
+ else
45
+ output_concise(routes)
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def parse_options(args)
52
+ OptionParser.new do |opts|
53
+ opts.banner = 'Usage: belt routes [options]'
54
+
55
+ opts.on('-g', '--grep PATTERN', 'Filter routes matching pattern') do |pattern|
56
+ @options[:grep] = pattern
57
+ end
58
+
59
+ opts.on('-f', '--format FORMAT', 'Output format: concise (default), json') do |format|
60
+ @options[:format] = format
61
+ end
62
+
63
+ opts.on('--namespace NAMESPACE', 'Generate Ruby route files for NAMESPACE (or "all")') do |ns|
64
+ @options[:namespace] = ns
65
+ end
66
+
67
+ opts.on('--output-dir DIR', 'Output directory for generated files') do |dir|
68
+ @options[:output_dir] = dir
69
+ end
70
+
71
+ opts.on('--schema FILE', 'Path to schema.tf.rb for model definitions') do |file|
72
+ @options[:schema_file] = file
73
+ end
74
+
75
+ opts.on('--tables-file FILE', 'Path to Terraform file with DynamoDB table definitions') do |file|
76
+ @options[:tables_file] = file
77
+ end
78
+
79
+ opts.on('-h', '--help', 'Show this help') do
80
+ puts opts
81
+ exit
82
+ end
83
+ end.parse!(args)
84
+ end
85
+
86
+ def find_routes_file
87
+ path = 'infrastructure/routes.tf.rb'
88
+ File.exist?(path) ? path : nil
89
+ end
90
+
91
+ def load_routes(file)
92
+ # Reset schema builder for clean state
93
+ Belt.instance_variable_set(:@application, nil)
94
+
95
+ content = File.read(file)
96
+ if content.include?('Belt.application.routes.draw')
97
+ binding_context = binding
98
+ eval(content, binding_context, file) # rubocop:disable Security/Eval
99
+ else
100
+ Belt::RouteDSL.load_from_file(file)
101
+ end
102
+ end
103
+
104
+ def collect_routes(dsl)
105
+ routes = []
106
+ dsl.api_gateways.each do |gateway|
107
+ gateway.routes.each do |route|
108
+ routes << build_route_hash(route, gateway)
109
+ end
110
+ end
111
+ routes.sort_by { |r| route_specificity(r[:path], r[:verb]) }
112
+ end
113
+
114
+ def build_route_hash(route, gateway)
115
+ hash = {
116
+ name: extract_route_name(route.path),
117
+ verb: route.method,
118
+ path: normalize_path(route.path),
119
+ gateway: gateway.name,
120
+ lambda: route.lambda.to_s,
121
+ controller: infer_controller(route, gateway),
122
+ action: infer_action(route, gateway),
123
+ auth: route.auth.to_s,
124
+ tables: get_route_tables(route),
125
+ request_model: route.request_model.to_s,
126
+ response_model: route.response_model.to_s
127
+ }
128
+ rc = route.response_context.to_s
129
+ hash[:response_context] = rc unless rc.empty?
130
+ hash
131
+ end
132
+
133
+ def get_route_tables(route)
134
+ if route.tables.any?
135
+ route.tables.map(&:to_s)
136
+ else
137
+ @table_inference.infer_tables_from_route(route)
138
+ end
139
+ end
140
+
141
+ def extract_route_name(path)
142
+ segments = path.split('/').reject(&:empty?)
143
+ return 'root' if segments.empty?
144
+
145
+ segments.reject { |s| s.start_with?('{', ':') }
146
+ .map { |s| s.gsub('-', '_') }
147
+ .join('_')
148
+ end
149
+
150
+ def output_ruby(routes, namespace, _routes_file)
151
+ output_dir = @options[:output_dir] || File.join(Belt.root, 'lambda/lib/routes')
152
+ FileUtils.mkdir_p(output_dir)
153
+ puts "Writing to #{output_dir}/:"
154
+
155
+ if namespace == 'all'
156
+ generate_all_manifests(routes, output_dir)
157
+ else
158
+ # Generate gateway-based manifest (all routes for this gateway)
159
+ filtered = routes.select { |r| r[:gateway] == namespace }
160
+ # Fall back to lambda-based if no gateway match
161
+ filtered = routes.select { |r| r[:lambda] == namespace } if filtered.empty?
162
+ if filtered.empty?
163
+ warn "No routes found for namespace '#{namespace}' - skipping"
164
+ return
165
+ end
166
+ write_ruby_manifest(filtered, namespace, output_dir)
167
+ end
168
+ end
169
+
170
+ def generate_all_manifests(routes, output_dir)
171
+ # Gateway-based manifests (primary — used by main Lambda entry points)
172
+ by_gateway = routes.group_by { |r| r[:gateway] }
173
+ by_gateway.each { |gw, gw_routes| write_ruby_manifest(gw_routes, gw, output_dir) }
174
+
175
+ # Scoped lambda manifests (where lambda != gateway — separate Lambda functions)
176
+ scoped = routes.reject { |r| r[:lambda] == r[:gateway] }
177
+ by_lambda = scoped.group_by { |r| r[:lambda] }
178
+ by_lambda.each { |lam, lam_routes| write_ruby_manifest(lam_routes, lam, output_dir) }
179
+ end
180
+
181
+ def write_ruby_manifest(routes, name, output_dir)
182
+ output_file = File.join(output_dir, "#{name}_routes.rb")
183
+ content = generate_ruby_content(routes, name)
184
+ File.write(output_file, content)
185
+ puts " ✅ #{name}_routes.rb (#{routes.length} routes)"
186
+ end
187
+
188
+ def generate_ruby_content(routes, namespace)
189
+ constant_name = namespace.upcase
190
+ lines = [
191
+ '# frozen_string_literal: true',
192
+ '',
193
+ "# Auto-generated by: belt routes --namespace #{namespace}",
194
+ '# Do not edit manually',
195
+ '',
196
+ 'module Routes',
197
+ " #{constant_name} = ["
198
+ ]
199
+
200
+ routes.each_with_index do |route, index|
201
+ lines << ' {'
202
+ lines << " verb: #{route[:verb].inspect},"
203
+ lines << " path: #{route[:path].inspect},"
204
+ lines << " gateway: #{route[:gateway].inspect},"
205
+ lines << " lambda: #{route[:lambda].inspect},"
206
+ lines << " controller: #{route[:controller].inspect},"
207
+ lines << " action: #{route[:action].inspect},"
208
+ lines << " auth: #{route[:auth].inspect},"
209
+ tables_syms = route[:tables].map { |t| ":#{t}" }.join(', ')
210
+ lines << " tables: [#{tables_syms}]"
211
+ lines << " }#{',' if index < routes.length - 1}"
212
+ end
213
+
214
+ lines << ' ].freeze'
215
+ lines << 'end'
216
+ lines << ''
217
+ lines.join("\n")
218
+ end
219
+
220
+ def normalize_path(path)
221
+ path = "/#{path}" unless path.start_with?('/')
222
+ normalized = path.gsub(%r{/([a-zA-Z_][a-zA-Z0-9_]*?)/:id(/|$)}) do
223
+ resource = ::Regexp.last_match(1)
224
+ trailing = ::Regexp.last_match(2)
225
+ singular = singularize(resource)
226
+ "/#{resource}/{#{singular}_id}#{trailing}"
227
+ end
228
+ normalized.gsub(/:([a-zA-Z_][a-zA-Z0-9_]*)/) { "{#{::Regexp.last_match(1)}}" }
229
+ end
230
+
231
+ def apply_grep(routes)
232
+ pattern = Regexp.new(@options[:grep], Regexp::IGNORECASE)
233
+ routes.select do |r|
234
+ r[:path].match?(pattern) ||
235
+ r[:gateway].to_s.match?(pattern) ||
236
+ r[:lambda].match?(pattern) ||
237
+ r[:verb].match?(pattern) ||
238
+ r[:controller].match?(pattern) ||
239
+ r[:action].match?(pattern)
240
+ end
241
+ end
242
+
243
+ def output_concise(routes)
244
+ return puts('No routes defined.') if routes.empty?
245
+
246
+ multi_gateway = routes.map { |r| r[:gateway] }.uniq.length > 1
247
+ verb_w = [routes.map { |r| r[:verb].length }.max, 6].max
248
+ path_w = [routes.map { |r| r[:path].length }.max, 4].max
249
+
250
+ if multi_gateway
251
+ output_concise_multi_gateway(routes, verb_w, path_w)
252
+ else
253
+ output_concise_single_gateway(routes, verb_w, path_w)
254
+ end
255
+ end
256
+
257
+ def output_concise_multi_gateway(routes, verb_w, path_w)
258
+ gw_w = [routes.map { |r| r[:gateway].to_s.length }.max, 7].max
259
+ lam_w = [routes.map { |r| r[:lambda].length }.max, 6].max
260
+
261
+ header = "#{'VERB'.ljust(verb_w)} #{'PATH'.ljust(path_w)} " \
262
+ "#{'GATEWAY'.ljust(gw_w)} #{'LAMBDA'.ljust(lam_w)} CONTROLLER#ACTION"
263
+ puts header
264
+ puts '-' * (verb_w + path_w + gw_w + lam_w + 20)
265
+
266
+ routes.each do |r|
267
+ line = "#{r[:verb].ljust(verb_w)} #{r[:path].ljust(path_w)} " \
268
+ "#{r[:gateway].to_s.ljust(gw_w)} #{r[:lambda].ljust(lam_w)} " \
269
+ "#{r[:controller]}##{r[:action]}"
270
+ puts line
271
+ end
272
+ end
273
+
274
+ def output_concise_single_gateway(routes, verb_w, path_w)
275
+ puts "#{'VERB'.ljust(verb_w)} #{'PATH'.ljust(path_w)} CONTROLLER#ACTION"
276
+ puts '-' * (verb_w + path_w + 30)
277
+
278
+ routes.each do |r|
279
+ puts "#{r[:verb].ljust(verb_w)} #{r[:path].ljust(path_w)} #{r[:controller]}##{r[:action]}"
280
+ end
281
+ end
282
+
283
+ def route_specificity(path, verb)
284
+ segments = path.split('/').reject(&:empty?)
285
+ param_count = segments.count { |s| s.start_with?('{') }
286
+ segment_count = segments.length
287
+ [param_count, -segment_count, path, verb_order(verb)]
288
+ end
289
+
290
+ def verb_order(verb)
291
+ { 'GET' => 0, 'POST' => 1, 'PUT' => 2, 'PATCH' => 3, 'DELETE' => 4 }[verb] || 99
292
+ end
293
+
294
+ def singularize(word)
295
+ if word.end_with?('ies')
296
+ "#{word[0..-4]}y"
297
+ elsif word.end_with?('ses') || word.end_with?('xes') || word.end_with?('zes')
298
+ word[0..-3]
299
+ elsif word.end_with?('s') && !word.end_with?('ss')
300
+ word[0..-2]
301
+ else
302
+ word
303
+ end
304
+ end
305
+ end
306
+ end
307
+ end
@@ -0,0 +1,261 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'shellwords'
5
+ require 'open3'
6
+ require_relative 'app_detection'
7
+ require_relative 'bucket_security'
8
+ require_relative 'tables_command'
9
+ require_relative 'frontend_setup_command'
10
+
11
+ module Belt
12
+ module CLI
13
+ class SetupCommand
14
+ SUBCOMMANDS = %w[state tables frontend].freeze
15
+
16
+ SECURITY_CHECKS = %i[versioning encryption public_access_block tls_policy].freeze
17
+
18
+ include AppDetection
19
+ include BucketSecurity
20
+
21
+ def self.run(args)
22
+ subcommand = args.shift
23
+
24
+ case subcommand
25
+ when 'state'
26
+ new(args).run_state_setup
27
+ when 'tables'
28
+ Belt::CLI::TablesCommand.run(args)
29
+ when 'frontend'
30
+ Belt::CLI::FrontendSetupCommand.run(args)
31
+ else
32
+ puts 'Usage: belt setup <state|tables|frontend> [options]'
33
+ puts "\nSubcommands:"
34
+ puts ' state Set up S3 bucket for Terraform state'
35
+ puts ' tables Generate DynamoDB table definitions from schema.tf.rb'
36
+ puts ' frontend Generate S3 + CloudFront infrastructure for frontend hosting'
37
+ exit 1
38
+ end
39
+ end
40
+
41
+ def initialize(args = [])
42
+ @app_name = detect_app_name
43
+ @env_name = nil
44
+ @custom_bucket = nil
45
+ @select_mode = false
46
+
47
+ parse_args(args)
48
+
49
+ @region = detect_region
50
+ @bucket_name = resolve_bucket_name
51
+ end
52
+
53
+ def run_state_setup
54
+ unless aws_configured?
55
+ puts '✗ AWS credentials not configured. Set AWS_PROFILE or configure aws sso login.'
56
+ exit 1
57
+ end
58
+
59
+ @bucket_name = interactive_bucket_selection if @select_mode
60
+ setup_or_verify_bucket
61
+ apply_lifecycle(@bucket_name)
62
+ puts ' ensure lifecycle rules (90-day noncurrent expiration)'
63
+ update_backend_config
64
+ print_success_message
65
+ end
66
+
67
+ def setup_or_verify_bucket
68
+ if bucket_exists?(@bucket_name)
69
+ verify_existing_bucket
70
+ else
71
+ create_new_bucket
72
+ end
73
+ end
74
+
75
+ def verify_existing_bucket
76
+ puts "Found existing bucket: #{@bucket_name}"
77
+ audit = audit_bucket_security(@bucket_name)
78
+ print_security_audit(audit)
79
+
80
+ if audit.values.all?
81
+ puts "\n✓ Bucket '#{@bucket_name}' passes all security checks"
82
+ else
83
+ prompt_and_harden(audit)
84
+ end
85
+ end
86
+
87
+ def prompt_and_harden(audit)
88
+ puts "\n⚠ Bucket '#{@bucket_name}' has security issues."
89
+ print "\nApply security hardening? [Y/n] "
90
+ response = $stdin.gets&.strip&.downcase
91
+ if response.nil? || response.empty? || response == 'y'
92
+ harden_bucket(@bucket_name, audit)
93
+ else
94
+ puts '✗ Refusing to use insecure bucket. Fix manually or choose a different bucket.'
95
+ exit 1
96
+ end
97
+ end
98
+
99
+ def create_new_bucket
100
+ puts "Creating state bucket: #{@bucket_name} (#{@region})"
101
+ create_bucket(@bucket_name)
102
+ puts " create s3://#{@bucket_name}"
103
+ harden_bucket(@bucket_name, {})
104
+ end
105
+
106
+ def print_success_message
107
+ puts "\n✓ State bucket '#{@bucket_name}' is ready!"
108
+ if @env_name
109
+ puts "\n cd infrastructure/#{@env_name} && terraform init"
110
+ else
111
+ puts "\n cd infrastructure/<env> && terraform init"
112
+ end
113
+ end
114
+
115
+ private
116
+
117
+ def parse_args(args)
118
+ while (arg = args.shift)
119
+ case arg
120
+ when '--bucket'
121
+ @custom_bucket = args.shift
122
+ abort '✗ --bucket requires a value' unless @custom_bucket
123
+ when '--select'
124
+ @select_mode = true
125
+ when '--help', '-h'
126
+ self.class.run([])
127
+ else
128
+ @env_name = arg unless arg.start_with?('-')
129
+ end
130
+ end
131
+ end
132
+
133
+ def resolve_bucket_name
134
+ if @custom_bucket
135
+ @custom_bucket
136
+ elsif @env_name
137
+ "#{@app_name}-terraform-state-#{@env_name}"
138
+ else
139
+ "#{@app_name}-terraform-state"
140
+ end
141
+ end
142
+
143
+ # --- Interactive selection ---
144
+
145
+ def interactive_bucket_selection
146
+ puts "Listing S3 buckets in account...\n\n"
147
+ buckets = list_buckets
148
+ if buckets.empty?
149
+ puts 'No buckets found. Creating a new one.'
150
+ return @bucket_name
151
+ end
152
+
153
+ # Show buckets with index
154
+ buckets.each_with_index do |b, i|
155
+ puts " [#{i + 1}] #{b}"
156
+ end
157
+ puts " [N] Create new bucket (#{@bucket_name})"
158
+ puts ''
159
+ print "Select bucket [1-#{buckets.size}] or N for new: "
160
+ choice = $stdin.gets&.strip
161
+
162
+ if choice.nil? || choice.downcase == 'n' || choice.empty?
163
+ @bucket_name
164
+ else
165
+ idx = choice.to_i - 1
166
+ if idx >= 0 && idx < buckets.size
167
+ buckets[idx]
168
+ else
169
+ puts '✗ Invalid selection'
170
+ exit 1
171
+ end
172
+ end
173
+ end
174
+
175
+ def list_buckets
176
+ output = safe_capture('aws', 's3api', 'list-buckets', '--query', 'Buckets[].Name', '--output', 'json')
177
+ return [] unless output
178
+
179
+ JSON.parse(output)
180
+ rescue JSON::ParserError
181
+ []
182
+ end
183
+
184
+ # --- AWS operations ---
185
+
186
+ def aws_configured?
187
+ system('aws', 'sts', 'get-caller-identity', out: File::NULL, err: File::NULL)
188
+ end
189
+
190
+ def bucket_exists?(bucket)
191
+ system('aws', 's3api', 'head-bucket', '--bucket', bucket, err: File::NULL)
192
+ end
193
+
194
+ def create_bucket(bucket)
195
+ args = ['aws', 's3api', 'create-bucket', '--bucket', bucket, '--region', @region]
196
+ args.push('--create-bucket-configuration', "LocationConstraint=#{@region}") unless @region == 'us-east-1'
197
+ run!(*args)
198
+ end
199
+
200
+ def apply_lifecycle(bucket)
201
+ lifecycle = {
202
+ Rules: [{
203
+ ID: 'expire-noncurrent-versions',
204
+ Status: 'Enabled',
205
+ Filter: {},
206
+ NoncurrentVersionExpiration: { NoncurrentDays: 90 },
207
+ AbortIncompleteMultipartUpload: { DaysAfterInitiation: 7 }
208
+ }]
209
+ }
210
+ run!('aws', 's3api', 'put-bucket-lifecycle-configuration', '--bucket', bucket,
211
+ '--lifecycle-configuration', JSON.generate(lifecycle))
212
+ end
213
+
214
+ def run!(*args)
215
+ return if system(*args)
216
+
217
+ puts "\n✗ Command failed: #{args.shelljoin}"
218
+ exit 1
219
+ end
220
+
221
+ def safe_capture(*)
222
+ output, status = Open3.capture2(*, err: File::NULL)
223
+ status.success? ? output : nil
224
+ end
225
+
226
+ # --- Backend config ---
227
+
228
+ def update_backend_config
229
+ dirs = if @env_name
230
+ [File.join('infrastructure', @env_name)]
231
+ else
232
+ Dir.glob('infrastructure/*/').select { |d| File.exist?(File.join(d, 'backend.tf')) }
233
+ end
234
+
235
+ dirs.each do |env_dir|
236
+ next unless Dir.exist?(env_dir)
237
+
238
+ backend_file = File.join(env_dir, 'backend.tf')
239
+ next unless File.exist?(backend_file)
240
+
241
+ content = File.read(backend_file)
242
+ updated = content.gsub(/bucket\s*=\s*"[^"]+"/, "bucket = \"#{@bucket_name}\"")
243
+ if updated != content
244
+ File.write(backend_file, updated)
245
+ puts " update #{backend_file} → bucket = \"#{@bucket_name}\""
246
+ end
247
+ end
248
+ end
249
+
250
+ # --- Detection ---
251
+
252
+ def detect_region
253
+ Dir.glob('infrastructure/*/backend.tf').each do |f|
254
+ match = File.read(f).match(/region\s*=\s*"([^"]+)"/)
255
+ return match[1] if match
256
+ end
257
+ 'us-east-1'
258
+ end
259
+ end
260
+ end
261
+ end
@@ -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
+ # Belt.application.schema.draw do ... end → parser.instance_eval do ... end
63
+ inner = schema_content.sub(/\A(?:Belt\.application)\.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