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