belt 0.0.6 → 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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +4 -0
  4. data/exe/belt +6 -0
  5. data/lib/belt/action_router.rb +7 -1
  6. data/lib/belt/cli/app_detection.rb +16 -0
  7. data/lib/belt/cli/bucket_security.rb +122 -0
  8. data/lib/belt/cli/env_resolver.rb +15 -0
  9. data/lib/belt/cli/environment_command.rb +77 -0
  10. data/lib/belt/cli/frontend_command.rb +85 -0
  11. data/lib/belt/cli/frontend_deploy_command.rb +125 -0
  12. data/lib/belt/cli/frontend_setup_command.rb +64 -0
  13. data/lib/belt/cli/generate_command.rb +206 -0
  14. data/lib/belt/cli/new_command.rb +125 -0
  15. data/lib/belt/cli/setup_command.rb +261 -0
  16. data/lib/belt/cli/tables_command.rb +138 -0
  17. data/lib/belt/cli/terraform_command.rb +77 -0
  18. data/lib/belt/cli/views_command.rb +134 -0
  19. data/lib/belt/cli.rb +98 -0
  20. data/lib/belt/lambda_handler.rb +16 -0
  21. data/lib/belt/version.rb +1 -1
  22. data/lib/belt.rb +1 -1
  23. data/lib/templates/environment/backend.tf.erb +8 -0
  24. data/lib/templates/environment/main.tf.erb +42 -0
  25. data/lib/templates/environment/terraform.tfvars.erb +1 -0
  26. data/lib/templates/environment/variables.tf.erb +16 -0
  27. data/lib/templates/frontend/react/index.html.erb +12 -0
  28. data/lib/templates/frontend/react/package.json.erb +20 -0
  29. data/lib/templates/frontend/react/src/App.jsx +14 -0
  30. data/lib/templates/frontend/react/src/index.css +10 -0
  31. data/lib/templates/frontend/react/src/lib/apiClient.js.erb +19 -0
  32. data/lib/templates/frontend/react/src/main.jsx +10 -0
  33. data/lib/templates/frontend/react/src/pages/Home.jsx.erb +10 -0
  34. data/lib/templates/frontend/react/vite.config.js +8 -0
  35. data/lib/templates/frontend_infra/frontend.tf.erb +159 -0
  36. data/lib/templates/generate/controller.rb.erb +59 -0
  37. data/lib/templates/generate/model.rb.erb +20 -0
  38. data/lib/templates/new_app/AGENTS.md.erb +130 -0
  39. data/lib/templates/new_app/Gemfile.erb +6 -0
  40. data/lib/templates/new_app/README.md.erb +25 -0
  41. data/lib/templates/new_app/gitignore.erb +14 -0
  42. data/lib/templates/new_app/infrastructure/routes.tf.rb.erb +5 -0
  43. data/lib/templates/new_app/infrastructure/schema.tf.rb.erb +9 -0
  44. data/lib/templates/new_app/lambda/Gemfile.erb +7 -0
  45. data/lib/templates/new_app/lambda/api.rb.erb +22 -0
  46. data/lib/templates/new_app/lambda/controllers/application_controller.rb.erb +6 -0
  47. data/lib/templates/new_app/lambda/lib/routes/routes.rb.erb +11 -0
  48. data/lib/templates/new_app/lambda/models/application_record.rb.erb +6 -0
  49. data/lib/templates/new_app/lambda/models/concerns/timestampable.rb.erb +23 -0
  50. data/lib/templates/views/Edit.jsx.erb +38 -0
  51. data/lib/templates/views/Form.jsx.erb +34 -0
  52. data/lib/templates/views/Index.jsx.erb +39 -0
  53. data/lib/templates/views/New.jsx.erb +26 -0
  54. data/lib/templates/views/Show.jsx.erb +46 -0
  55. data.tar.gz.sig +0 -0
  56. metadata +51 -3
  57. metadata.gz.sig +0 -0
@@ -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,125 @@
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
+ 'lambda/Gemfile.erb' => "#{@app_name}/lambda/Gemfile",
83
+ 'lambda/api.rb.erb' => "#{@app_name}/lambda/#{@app_name}.rb",
84
+ 'lambda/models/application_record.rb.erb' => "#{@app_name}/lambda/models/application_record.rb",
85
+ 'lambda/models/concerns/timestampable.rb.erb' => "#{@app_name}/lambda/models/concerns/timestampable.rb",
86
+ 'lambda/controllers/application_controller.rb.erb' =>
87
+ "#{@app_name}/lambda/controllers/#{@app_name}/application_controller.rb",
88
+ 'lambda/lib/routes/routes.rb.erb' => "#{@app_name}/lambda/lib/routes/#{@app_name}_routes.rb",
89
+ 'infrastructure/routes.tf.rb.erb' => "#{@app_name}/infrastructure/routes.tf.rb",
90
+ 'infrastructure/schema.tf.rb.erb' => "#{@app_name}/infrastructure/schema.tf.rb",
91
+ 'README.md.erb' => "#{@app_name}/README.md",
92
+ 'AGENTS.md.erb' => "#{@app_name}/AGENTS.md",
93
+ 'gitignore.erb' => "#{@app_name}/.gitignore"
94
+ }
95
+ end
96
+
97
+ def create_dir(dir)
98
+ FileUtils.mkdir_p(dir)
99
+ puts " create #{dir}/"
100
+ end
101
+
102
+ def create_file(template_name, dest_path)
103
+ template_path = File.join(TEMPLATE_DIR, template_name)
104
+ content = ERB.new(File.read(template_path), trim_mode: '-').result(binding)
105
+ File.write(dest_path, content)
106
+ puts " create #{dest_path}"
107
+ end
108
+
109
+ def init_git
110
+ Dir.chdir(@app_name) do
111
+ system('git', 'init', '--quiet')
112
+ system('git', 'add', '.')
113
+ system('git', 'commit', '-m', 'Initial commit', '--quiet')
114
+ end
115
+ puts " init #{@app_name}/.git/"
116
+ end
117
+
118
+ def generate_frontend
119
+ Dir.chdir(@app_name) do
120
+ Belt::CLI::FrontendCommand.new(@frontend).generate
121
+ end
122
+ end
123
+ end
124
+ end
125
+ 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