belt 0.1.4 → 0.1.5
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
- data/lib/belt/action_router.rb +7 -0
- data/lib/belt/assets/belt-default.png +0 -0
- data/lib/belt/assets/welcome.css +112 -0
- data/lib/belt/cli/deploy_command.rb +182 -0
- data/lib/belt/cli/environment_command.rb +12 -7
- data/lib/belt/cli/generate_command.rb +164 -9
- data/lib/belt/cli/new_command.rb +164 -21
- data/lib/belt/cli/server_command.rb +302 -0
- data/lib/belt/cli/setup_command.rb +40 -6
- data/lib/belt/cli.rb +12 -11
- data/lib/belt/controllers/welcome_controller.rb +102 -0
- data/lib/belt/version.rb +1 -1
- data/lib/belt.rb +1 -0
- data/lib/templates/environment/main.tf.erb +1 -0
- data/lib/templates/environment/outputs.tf.erb +9 -0
- data/lib/templates/new_app/infrastructure/routes.tf.rb.erb +1 -0
- data/lib/templates/new_app/lambda/lib/routes/routes.rb.erb +1 -0
- metadata +7 -1
data/lib/belt/cli/new_command.rb
CHANGED
|
@@ -2,40 +2,76 @@
|
|
|
2
2
|
|
|
3
3
|
require 'fileutils'
|
|
4
4
|
require 'erb'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'open3'
|
|
7
|
+
require_relative 'setup_command'
|
|
5
8
|
|
|
6
9
|
module Belt
|
|
7
10
|
module CLI
|
|
8
11
|
class NewCommand
|
|
9
12
|
TEMPLATE_DIR = File.expand_path('../../templates/new_app', __dir__)
|
|
13
|
+
DEFAULT_ENVIRONMENTS = %w[dev prod].freeze
|
|
10
14
|
|
|
11
15
|
def self.run(args)
|
|
12
16
|
app_name = nil
|
|
13
17
|
frontend = nil
|
|
18
|
+
bucket = nil
|
|
19
|
+
environments = nil
|
|
14
20
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
i = 0
|
|
22
|
+
while i < args.length
|
|
23
|
+
arg = args[i]
|
|
24
|
+
case arg
|
|
25
|
+
when '--frontend'
|
|
26
|
+
if arg.include?('=')
|
|
27
|
+
frontend = arg.split('=', 2).last
|
|
28
|
+
else
|
|
29
|
+
i += 1
|
|
30
|
+
frontend = args[i]
|
|
31
|
+
end
|
|
32
|
+
when /^--frontend=/
|
|
33
|
+
frontend = arg.split('=', 2).last
|
|
34
|
+
when '--bucket', '--state-bucket'
|
|
35
|
+
i += 1
|
|
36
|
+
bucket = args[i]
|
|
37
|
+
when /^--bucket=/
|
|
38
|
+
bucket = arg.split('=', 2).last
|
|
39
|
+
when /^--state-bucket=/
|
|
40
|
+
bucket = arg.split('=', 2).last
|
|
41
|
+
when '--environments'
|
|
42
|
+
i += 1
|
|
43
|
+
environments = args[i]
|
|
44
|
+
when /^--environments=/
|
|
45
|
+
environments = arg.split('=', 2).last
|
|
46
|
+
else
|
|
47
|
+
app_name ||= arg unless arg.start_with?('-')
|
|
24
48
|
end
|
|
49
|
+
i += 1
|
|
25
50
|
end
|
|
26
51
|
|
|
27
52
|
if app_name.nil? || app_name.empty?
|
|
28
|
-
puts 'Usage: belt new <app_name> [
|
|
53
|
+
puts 'Usage: belt new <app_name> [options]'
|
|
54
|
+
puts ''
|
|
55
|
+
puts 'Options:'
|
|
56
|
+
puts ' --frontend react|vue|svelte Set up frontend framework'
|
|
57
|
+
puts ' --bucket BUCKET_NAME S3 bucket for Terraform state'
|
|
58
|
+
puts ' --state-bucket BUCKET_NAME Alias for --bucket'
|
|
59
|
+
puts ' --environments dev,prod Comma-separated environments (default: dev,prod)'
|
|
60
|
+
puts ' Use "none" to skip environment setup'
|
|
29
61
|
exit 1
|
|
30
62
|
end
|
|
31
63
|
|
|
32
|
-
new(app_name, frontend: frontend).generate
|
|
64
|
+
new(app_name, frontend: frontend, bucket: bucket, environments: environments).generate
|
|
33
65
|
end
|
|
34
66
|
|
|
35
|
-
def initialize(app_name, frontend: nil)
|
|
67
|
+
def initialize(app_name, frontend: nil, bucket: nil, environments: nil)
|
|
36
68
|
@app_name = app_name.gsub(/[^a-z0-9_-]/i, '_').downcase
|
|
37
69
|
@module_name = @app_name.split(/[-_]/).map(&:capitalize).join
|
|
38
70
|
@frontend = frontend
|
|
71
|
+
@bucket = bucket
|
|
72
|
+
@environments = parse_environments(environments)
|
|
73
|
+
@resolved_bucket = nil
|
|
74
|
+
@state_setup_succeeded = false
|
|
39
75
|
end
|
|
40
76
|
|
|
41
77
|
def generate
|
|
@@ -47,19 +83,23 @@ module Belt
|
|
|
47
83
|
puts "Creating new Belt application: #{@app_name}"
|
|
48
84
|
create_structure
|
|
49
85
|
generate_frontend if @frontend
|
|
86
|
+
generate_environments
|
|
50
87
|
init_git
|
|
88
|
+
run_bundle_install
|
|
89
|
+
setup_state
|
|
51
90
|
puts "\n✓ #{@app_name} created successfully!"
|
|
52
|
-
|
|
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}/"
|
|
91
|
+
print_next_steps
|
|
59
92
|
end
|
|
60
93
|
|
|
61
94
|
private
|
|
62
95
|
|
|
96
|
+
def parse_environments(env_string)
|
|
97
|
+
return DEFAULT_ENVIRONMENTS if env_string.nil? || env_string.empty?
|
|
98
|
+
return [] if env_string.downcase == 'none'
|
|
99
|
+
|
|
100
|
+
env_string.split(',').map(&:strip).reject(&:empty?)
|
|
101
|
+
end
|
|
102
|
+
|
|
63
103
|
def create_structure
|
|
64
104
|
directories.each { |dir| create_dir(dir) }
|
|
65
105
|
files.each { |src, dest| create_file(src, dest) }
|
|
@@ -96,6 +136,84 @@ module Belt
|
|
|
96
136
|
}
|
|
97
137
|
end
|
|
98
138
|
|
|
139
|
+
def generate_environments
|
|
140
|
+
return if @environments.empty?
|
|
141
|
+
|
|
142
|
+
Dir.chdir(@app_name) do
|
|
143
|
+
@environments.each do |env_name|
|
|
144
|
+
Belt::CLI::EnvironmentCommand.new(env_name, quiet: true).generate
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def setup_state
|
|
150
|
+
return if @environments.empty?
|
|
151
|
+
|
|
152
|
+
Dir.chdir(@app_name) do
|
|
153
|
+
# Resolve bucket name — include account ID + region if AWS credentials are available
|
|
154
|
+
if @bucket
|
|
155
|
+
@resolved_bucket = @bucket
|
|
156
|
+
elsif aws_configured?
|
|
157
|
+
region = detect_region
|
|
158
|
+
@resolved_bucket = s3_safe_name("#{@app_name}-terraform-state-#{@aws_account_id}-#{region}")
|
|
159
|
+
else
|
|
160
|
+
@resolved_bucket = s3_safe_name("#{@app_name}-terraform-state")
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Update backend.tf files with the resolved bucket name
|
|
164
|
+
@environments.each do |env_name|
|
|
165
|
+
backend_file = "infrastructure/#{env_name}/backend.tf"
|
|
166
|
+
next unless File.exist?(backend_file)
|
|
167
|
+
|
|
168
|
+
content = File.read(backend_file)
|
|
169
|
+
updated = content.gsub(/bucket\s*=\s*"[^"]+"/, "bucket = \"#{@resolved_bucket}\"")
|
|
170
|
+
File.write(backend_file, updated) if updated != content
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Attempt to actually create the bucket if credentials are available
|
|
174
|
+
if @aws_account_id
|
|
175
|
+
puts "\n Setting up Terraform state bucket..."
|
|
176
|
+
begin
|
|
177
|
+
Belt::CLI::SetupCommand.new(["--bucket", @resolved_bucket]).run_state_setup
|
|
178
|
+
@state_setup_succeeded = true
|
|
179
|
+
rescue SystemExit
|
|
180
|
+
puts ' ⚠ State bucket setup encountered an issue — run `belt setup state` to retry.'
|
|
181
|
+
@state_setup_succeeded = false
|
|
182
|
+
end
|
|
183
|
+
else
|
|
184
|
+
puts "\n State bucket: #{@resolved_bucket}"
|
|
185
|
+
if @aws_error&.include?('ForbiddenException') || @aws_error&.include?('AccessDenied')
|
|
186
|
+
puts " ⚠ AWS credentials found but access denied — check your profile/role configuration."
|
|
187
|
+
puts " #{@aws_error}" if @aws_error
|
|
188
|
+
else
|
|
189
|
+
puts ' ⚠ AWS credentials not detected — skipping state bucket creation.'
|
|
190
|
+
end
|
|
191
|
+
puts ' Run `belt setup state` after configuring credentials (aws sso login / AWS_PROFILE).'
|
|
192
|
+
@state_setup_succeeded = false
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def detect_region
|
|
198
|
+
Dir.glob('infrastructure/*/backend.tf').each do |f|
|
|
199
|
+
match = File.read(f).match(/region\s*=\s*"([^"]+)"/)
|
|
200
|
+
return match[1] if match
|
|
201
|
+
end
|
|
202
|
+
'us-east-1'
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def aws_configured?
|
|
206
|
+
output, status = Open3.capture2e('aws', 'sts', 'get-caller-identity')
|
|
207
|
+
if status.success?
|
|
208
|
+
data = JSON.parse(output) rescue {}
|
|
209
|
+
@aws_account_id = data['Account']
|
|
210
|
+
true
|
|
211
|
+
else
|
|
212
|
+
@aws_error = output.strip
|
|
213
|
+
false
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
99
217
|
def create_dir(dir)
|
|
100
218
|
FileUtils.mkdir_p(dir)
|
|
101
219
|
puts " create #{dir}/"
|
|
@@ -111,17 +229,42 @@ module Belt
|
|
|
111
229
|
def init_git
|
|
112
230
|
Dir.chdir(@app_name) do
|
|
113
231
|
system('git', 'init', '--quiet')
|
|
114
|
-
system('git', 'add', '.')
|
|
115
|
-
system('git', 'commit', '-m', 'Initial commit', '--quiet')
|
|
116
232
|
end
|
|
117
233
|
puts " init #{@app_name}/.git/"
|
|
118
234
|
end
|
|
119
235
|
|
|
236
|
+
def run_bundle_install
|
|
237
|
+
Dir.chdir(@app_name) do
|
|
238
|
+
puts "\n Running bundle install..."
|
|
239
|
+
success = system('bundle', 'install', '--quiet')
|
|
240
|
+
if success
|
|
241
|
+
puts ' ✓ Bundle installed'
|
|
242
|
+
else
|
|
243
|
+
puts ' ⚠ bundle install failed — run it manually after resolving issues.'
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
120
248
|
def generate_frontend
|
|
121
249
|
Dir.chdir(@app_name) do
|
|
122
250
|
Belt::CLI::FrontendCommand.new(@frontend).generate
|
|
123
251
|
end
|
|
124
252
|
end
|
|
253
|
+
|
|
254
|
+
def print_next_steps
|
|
255
|
+
puts "\nNext steps:"
|
|
256
|
+
puts " cd #{@app_name}"
|
|
257
|
+
unless @state_setup_succeeded
|
|
258
|
+
puts ' # Configure AWS credentials (aws sso login / AWS_PROFILE)'
|
|
259
|
+
puts ' belt setup state # Create the S3 state bucket'
|
|
260
|
+
end
|
|
261
|
+
puts ' belt deploy # Deploy to AWS'
|
|
262
|
+
puts ' belt server # Start local frontend server' if @frontend
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def s3_safe_name(name)
|
|
266
|
+
name.to_s.downcase.tr('_', '-')
|
|
267
|
+
end
|
|
125
268
|
end
|
|
126
269
|
end
|
|
127
270
|
end
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'base64'
|
|
4
|
+
require 'json'
|
|
5
|
+
require_relative 'app_detection'
|
|
6
|
+
require_relative 'terraform_command'
|
|
7
|
+
|
|
8
|
+
module Belt
|
|
9
|
+
module CLI
|
|
10
|
+
class ServerCommand
|
|
11
|
+
include AppDetection
|
|
12
|
+
|
|
13
|
+
DEFAULT_PORT = 3000
|
|
14
|
+
|
|
15
|
+
def self.run(args)
|
|
16
|
+
port = DEFAULT_PORT
|
|
17
|
+
open_browser = false
|
|
18
|
+
|
|
19
|
+
i = 0
|
|
20
|
+
while i < args.length
|
|
21
|
+
case args[i]
|
|
22
|
+
when '-p', '--port'
|
|
23
|
+
i += 1
|
|
24
|
+
port = args[i].to_i
|
|
25
|
+
when /^--port=/
|
|
26
|
+
port = args[i].split('=', 2).last.to_i
|
|
27
|
+
when '-o', '--open'
|
|
28
|
+
open_browser = true
|
|
29
|
+
when '-h', '--help'
|
|
30
|
+
puts help_text
|
|
31
|
+
exit 0
|
|
32
|
+
end
|
|
33
|
+
i += 1
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
new(port: port, open_browser: open_browser).run
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.help_text
|
|
40
|
+
<<~HELP
|
|
41
|
+
Start a local development server for the frontend.
|
|
42
|
+
|
|
43
|
+
Usage: belt server [options]
|
|
44
|
+
belt s [options]
|
|
45
|
+
|
|
46
|
+
Options:
|
|
47
|
+
-p, --port PORT Port to serve on (default: #{DEFAULT_PORT})
|
|
48
|
+
-o, --open Open browser after starting
|
|
49
|
+
-h, --help Show this help
|
|
50
|
+
|
|
51
|
+
Behavior:
|
|
52
|
+
• If frontend/ exists → runs the frontend dev server (npm run dev)
|
|
53
|
+
Automatically sets VITE_API_URL from terraform outputs if deployed.
|
|
54
|
+
• If no frontend → serves the welcome page via a local HTTP server
|
|
55
|
+
After deploy, shows live API URL and deployment status.
|
|
56
|
+
|
|
57
|
+
Note: The backend is serverless (AWS Lambda). Use `belt deploy` to deploy
|
|
58
|
+
your backend to AWS. Local frontend development automatically points to
|
|
59
|
+
your deployed API via VITE_API_URL.
|
|
60
|
+
|
|
61
|
+
Examples:
|
|
62
|
+
belt server # Start on port #{DEFAULT_PORT}
|
|
63
|
+
belt s -p 4000 # Start on port 4000
|
|
64
|
+
belt s --open # Start and open browser
|
|
65
|
+
HELP
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def initialize(port:, open_browser: false)
|
|
69
|
+
@port = port
|
|
70
|
+
@open_browser = open_browser
|
|
71
|
+
@app_name = detect_app_name
|
|
72
|
+
@api_url = detect_api_url
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def run
|
|
76
|
+
if Dir.exist?('frontend') && File.exist?('frontend/package.json')
|
|
77
|
+
run_frontend_dev_server
|
|
78
|
+
else
|
|
79
|
+
run_welcome_server
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def run_frontend_dev_server
|
|
86
|
+
puts "🚀 Starting frontend dev server on port #{@port}..."
|
|
87
|
+
if @api_url
|
|
88
|
+
puts " Backend API: #{@api_url}"
|
|
89
|
+
else
|
|
90
|
+
puts ' Backend is serverless — deploy with `belt deploy` to set up AWS resources.'
|
|
91
|
+
end
|
|
92
|
+
puts ''
|
|
93
|
+
|
|
94
|
+
open_browser_later if @open_browser
|
|
95
|
+
|
|
96
|
+
env = { 'PORT' => @port.to_s }
|
|
97
|
+
env['VITE_API_URL'] = @api_url if @api_url
|
|
98
|
+
|
|
99
|
+
# Prefer the dev script with the port flag for Vite-based setups
|
|
100
|
+
Dir.chdir('frontend') do
|
|
101
|
+
exec(env, 'npx', 'vite', '--port', @port.to_s)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def run_welcome_server
|
|
106
|
+
if @api_url
|
|
107
|
+
puts "🚀 Serving Belt welcome page on http://localhost:#{@port}"
|
|
108
|
+
puts " Backend API: #{@api_url}"
|
|
109
|
+
puts ''
|
|
110
|
+
puts ' Tip: Run `belt generate frontend react` to scaffold a frontend app.'
|
|
111
|
+
puts ''
|
|
112
|
+
else
|
|
113
|
+
puts "🚀 Serving Belt welcome page on http://localhost:#{@port}"
|
|
114
|
+
puts ' No deployment detected — showing pre-deploy welcome page.'
|
|
115
|
+
puts ' Backend is serverless — deploy with `belt deploy` to set up AWS resources.'
|
|
116
|
+
puts ''
|
|
117
|
+
puts ' Tip: Run `belt generate frontend react` to scaffold a frontend app.'
|
|
118
|
+
puts ''
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
require 'webrick'
|
|
122
|
+
|
|
123
|
+
server = WEBrick::HTTPServer.new(Port: @port, Logger: WEBrick::Log.new('/dev/null'), AccessLog: [])
|
|
124
|
+
|
|
125
|
+
server.mount_proc '/' do |_req, res|
|
|
126
|
+
res['Content-Type'] = 'text/html; charset=utf-8'
|
|
127
|
+
res.body = welcome_html
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
open_browser_later if @open_browser
|
|
131
|
+
|
|
132
|
+
trap('INT') { server.shutdown }
|
|
133
|
+
trap('TERM') { server.shutdown }
|
|
134
|
+
|
|
135
|
+
server.start
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def gem_assets_dir
|
|
139
|
+
@gem_assets_dir ||= File.expand_path('../assets', __dir__)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def welcome_html
|
|
143
|
+
title = ENV.fetch('WELCOME_TITLE', 'Welcome to Belt')
|
|
144
|
+
subtitle = ENV.fetch('WELCOME_SUBTITLE', 'Your app is ready. Deploy to see it live on AWS.')
|
|
145
|
+
|
|
146
|
+
css = load_asset('welcome.css') || ''
|
|
147
|
+
image_b64 = load_image_base64('belt-default.png') || ''
|
|
148
|
+
|
|
149
|
+
if @api_url
|
|
150
|
+
deployed_html(css: css, image_b64: image_b64)
|
|
151
|
+
else
|
|
152
|
+
pre_deploy_html(title: title, subtitle: subtitle, css: css, image_b64: image_b64)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def load_asset(filename)
|
|
157
|
+
path = File.join(gem_assets_dir, filename)
|
|
158
|
+
File.exist?(path) ? File.read(path) : nil
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def load_image_base64(filename)
|
|
162
|
+
path = File.join(gem_assets_dir, filename)
|
|
163
|
+
File.exist?(path) ? Base64.strict_encode64(File.binread(path)) : nil
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def deployed_html(css:, image_b64:)
|
|
167
|
+
<<~HTML
|
|
168
|
+
<!DOCTYPE html>
|
|
169
|
+
<html lang="en">
|
|
170
|
+
<head>
|
|
171
|
+
<meta charset="UTF-8">
|
|
172
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
173
|
+
<title>#{@app_name} — Deployed</title>
|
|
174
|
+
<style>#{css}</style>
|
|
175
|
+
</head>
|
|
176
|
+
<body>
|
|
177
|
+
<div class="hero">
|
|
178
|
+
<img src="data:image/png;base64,#{image_b64}" alt="Belt" class="hero-bg" />
|
|
179
|
+
<div class="overlay">
|
|
180
|
+
<h1>#{@app_name}</h1>
|
|
181
|
+
<p class="subtitle">Deployed and running on AWS</p>
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
<div class="container">
|
|
185
|
+
<div class="next-steps">
|
|
186
|
+
<h2>Next Steps</h2>
|
|
187
|
+
<ol>
|
|
188
|
+
<li>View your live app: <a href="#{@api_url}">#{@api_url}</a> (#{@deploy_env || 'dev'})</li>
|
|
189
|
+
<li>Generate a resource: <code>belt g resource post title:string body:text</code></li>
|
|
190
|
+
<li>Set up a frontend: <code>belt generate frontend react</code></li>
|
|
191
|
+
</ol>
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
</body>
|
|
195
|
+
</html>
|
|
196
|
+
HTML
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def pre_deploy_html(title:, subtitle:, css:, image_b64:)
|
|
200
|
+
<<~HTML
|
|
201
|
+
<!DOCTYPE html>
|
|
202
|
+
<html lang="en">
|
|
203
|
+
<head>
|
|
204
|
+
<meta charset="UTF-8">
|
|
205
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
206
|
+
<title>#{title}</title>
|
|
207
|
+
<style>#{css}</style>
|
|
208
|
+
</head>
|
|
209
|
+
<body>
|
|
210
|
+
<div class="hero">
|
|
211
|
+
<img src="data:image/png;base64,#{image_b64}" alt="Belt" class="hero-bg" />
|
|
212
|
+
<div class="overlay">
|
|
213
|
+
<h1>#{title}</h1>
|
|
214
|
+
<p class="subtitle">#{subtitle}</p>
|
|
215
|
+
</div>
|
|
216
|
+
</div>
|
|
217
|
+
<div class="container">
|
|
218
|
+
<div class="info">
|
|
219
|
+
<p><strong>App:</strong> #{@app_name}</p>
|
|
220
|
+
<p><strong>Status:</strong> Not yet deployed</p>
|
|
221
|
+
</div>
|
|
222
|
+
<div class="next-steps">
|
|
223
|
+
<h2>Next Steps</h2>
|
|
224
|
+
<ol>
|
|
225
|
+
<li>Generate a resource: <code>belt g resource post title:string body:text</code></li>
|
|
226
|
+
<li>Deploy to AWS: <code>belt deploy</code></li>
|
|
227
|
+
<li>Set up a frontend: <code>belt generate frontend react</code></li>
|
|
228
|
+
</ol>
|
|
229
|
+
</div>
|
|
230
|
+
</div>
|
|
231
|
+
</body>
|
|
232
|
+
</html>
|
|
233
|
+
HTML
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Reads terraform outputs to find the deployed API URL.
|
|
237
|
+
# Checks all available environments, preferring BELT_ENV.
|
|
238
|
+
def detect_api_url
|
|
239
|
+
infra_dir = TerraformCommand.find_infrastructure_dir
|
|
240
|
+
return nil unless infra_dir
|
|
241
|
+
|
|
242
|
+
# Prefer BELT_ENV, then try each environment
|
|
243
|
+
envs = TerraformCommand.list_environments
|
|
244
|
+
preferred = ENV.fetch('BELT_ENV', nil)
|
|
245
|
+
envs.unshift(envs.delete(preferred)) if preferred && envs.include?(preferred)
|
|
246
|
+
|
|
247
|
+
envs.each do |env|
|
|
248
|
+
env_dir = File.join(infra_dir, env)
|
|
249
|
+
next unless Dir.exist?(env_dir)
|
|
250
|
+
next unless File.exist?(File.join(env_dir, '.terraform'))
|
|
251
|
+
|
|
252
|
+
url = read_api_url_from_outputs(env_dir)
|
|
253
|
+
if url
|
|
254
|
+
@deploy_env = env
|
|
255
|
+
return url
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
nil
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def read_api_url_from_outputs(env_dir)
|
|
263
|
+
output = nil
|
|
264
|
+
Dir.chdir(env_dir) do
|
|
265
|
+
# Use Open3 to check exit status — terraform writes errors to stdout too
|
|
266
|
+
require 'open3'
|
|
267
|
+
output, status = Open3.capture2('terraform', 'output', '-json', err: File::NULL)
|
|
268
|
+
return nil unless status.success?
|
|
269
|
+
end
|
|
270
|
+
return nil if output.nil? || output.strip.empty?
|
|
271
|
+
|
|
272
|
+
data = JSON.parse(output.strip)
|
|
273
|
+
|
|
274
|
+
# api_url output is a map like { "app_name" => "https://..." }
|
|
275
|
+
if data['api_url'] && data['api_url']['value']
|
|
276
|
+
value = data['api_url']['value']
|
|
277
|
+
case value
|
|
278
|
+
when String
|
|
279
|
+
value
|
|
280
|
+
when Hash
|
|
281
|
+
# Take the first URL from the map
|
|
282
|
+
value.values.first
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
rescue JSON::ParserError, Errno::ENOENT
|
|
286
|
+
nil
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def open_browser_later
|
|
290
|
+
Thread.new do
|
|
291
|
+
sleep 1.5
|
|
292
|
+
url = "http://localhost:#{@port}"
|
|
293
|
+
if RUBY_PLATFORM.include?('darwin')
|
|
294
|
+
system('open', url)
|
|
295
|
+
elsif RUBY_PLATFORM.include?('linux')
|
|
296
|
+
system('xdg-open', url, out: File::NULL, err: File::NULL)
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
end
|
|
@@ -52,10 +52,18 @@ module Belt
|
|
|
52
52
|
|
|
53
53
|
def run_state_setup
|
|
54
54
|
unless aws_configured?
|
|
55
|
-
|
|
55
|
+
if @aws_error&.include?('ForbiddenException') || @aws_error&.include?('AccessDenied')
|
|
56
|
+
puts "✗ AWS credentials found but access denied — check your profile/role configuration."
|
|
57
|
+
puts " #{@aws_error}"
|
|
58
|
+
else
|
|
59
|
+
puts '✗ AWS credentials not configured. Set AWS_PROFILE or configure aws sso login.'
|
|
60
|
+
end
|
|
56
61
|
exit 1
|
|
57
62
|
end
|
|
58
63
|
|
|
64
|
+
# Re-resolve bucket name with account ID suffix now that we have credentials
|
|
65
|
+
@bucket_name = resolve_bucket_name unless @custom_bucket
|
|
66
|
+
|
|
59
67
|
@bucket_name = interactive_bucket_selection if @select_mode
|
|
60
68
|
setup_or_verify_bucket
|
|
61
69
|
apply_lifecycle(@bucket_name)
|
|
@@ -133,10 +141,14 @@ module Belt
|
|
|
133
141
|
def resolve_bucket_name
|
|
134
142
|
if @custom_bucket
|
|
135
143
|
@custom_bucket
|
|
136
|
-
elsif @env_name
|
|
137
|
-
s3_safe_name("#{@app_name}-terraform-state-#{@env_name}")
|
|
138
144
|
else
|
|
139
|
-
|
|
145
|
+
base = if @env_name
|
|
146
|
+
"#{@app_name}-terraform-state-#{@env_name}"
|
|
147
|
+
else
|
|
148
|
+
"#{@app_name}-terraform-state"
|
|
149
|
+
end
|
|
150
|
+
base = "#{base}-#{@aws_account_id}-#{@region}" if @aws_account_id
|
|
151
|
+
s3_safe_name(base)
|
|
140
152
|
end
|
|
141
153
|
end
|
|
142
154
|
|
|
@@ -184,11 +196,33 @@ module Belt
|
|
|
184
196
|
# --- AWS operations ---
|
|
185
197
|
|
|
186
198
|
def aws_configured?
|
|
187
|
-
|
|
199
|
+
output, status = Open3.capture2e('aws', 'sts', 'get-caller-identity')
|
|
200
|
+
if status.success?
|
|
201
|
+
data = JSON.parse(output) rescue {}
|
|
202
|
+
@aws_account_id = data['Account']
|
|
203
|
+
true
|
|
204
|
+
else
|
|
205
|
+
@aws_error = output.strip
|
|
206
|
+
false
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def aws_account_id
|
|
211
|
+
@aws_account_id
|
|
188
212
|
end
|
|
189
213
|
|
|
190
214
|
def bucket_exists?(bucket)
|
|
191
|
-
|
|
215
|
+
output, status = Open3.capture2e('aws', 's3api', 'head-bucket', '--bucket', bucket)
|
|
216
|
+
return true if status.success?
|
|
217
|
+
|
|
218
|
+
# 403 = bucket exists but owned by someone else; 404 = doesn't exist
|
|
219
|
+
if output.include?('403') || output.include?('Forbidden')
|
|
220
|
+
puts "\n✗ Bucket '#{bucket}' exists but is owned by a different AWS account."
|
|
221
|
+
puts ' S3 bucket names are globally unique. Choose a different name with --bucket.'
|
|
222
|
+
exit 1
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
false
|
|
192
226
|
end
|
|
193
227
|
|
|
194
228
|
def create_bucket(bucket)
|
data/lib/belt/cli.rb
CHANGED
|
@@ -11,6 +11,8 @@ require_relative 'cli/frontend_deploy_command'
|
|
|
11
11
|
require_relative 'cli/views_command'
|
|
12
12
|
require_relative 'cli/setup_command'
|
|
13
13
|
require_relative 'cli/terraform_command'
|
|
14
|
+
require_relative 'cli/deploy_command'
|
|
15
|
+
require_relative 'cli/server_command'
|
|
14
16
|
require_relative 'cli/routes_command'
|
|
15
17
|
require_relative 'cli/tasks_command'
|
|
16
18
|
require_relative 'cli/console_command'
|
|
@@ -24,15 +26,8 @@ module Belt
|
|
|
24
26
|
%w[console c] => Belt::CLI::ConsoleCommand,
|
|
25
27
|
%w[tasks --tasks -T] => Belt::CLI::TasksCommand,
|
|
26
28
|
'setup' => Belt::CLI::SetupCommand,
|
|
27
|
-
'deploy' =>
|
|
28
|
-
|
|
29
|
-
if subcommand == 'frontend'
|
|
30
|
-
Belt::CLI::FrontendDeployCommand.run(args)
|
|
31
|
-
else
|
|
32
|
-
puts 'Usage: belt deploy frontend <environment>'
|
|
33
|
-
exit 1
|
|
34
|
-
end
|
|
35
|
-
},
|
|
29
|
+
'deploy' => Belt::CLI::DeployCommand,
|
|
30
|
+
%w[server s] => Belt::CLI::ServerCommand,
|
|
36
31
|
%w[version --version -v] => ->(_args) { puts "Belt #{Belt::VERSION}" }
|
|
37
32
|
}.freeze
|
|
38
33
|
|
|
@@ -80,6 +75,10 @@ module Belt
|
|
|
80
75
|
generate frontend <react|vue|svelte> Scaffold a frontend app
|
|
81
76
|
generate views <resource> [fields...] Generate React pages for REST actions
|
|
82
77
|
generate environment <name> Create a new environment
|
|
78
|
+
server Start local dev server (frontend)
|
|
79
|
+
s Alias for server
|
|
80
|
+
deploy [environment] Deploy to AWS (init → plan → apply)
|
|
81
|
+
deploy frontend <env> Build and deploy frontend to AWS
|
|
83
82
|
routes [-g PATTERN] [-f json] Show route definitions
|
|
84
83
|
console Start an interactive console (IRB)
|
|
85
84
|
c Alias for console
|
|
@@ -88,7 +87,6 @@ module Belt
|
|
|
88
87
|
setup state Create/select S3 state bucket
|
|
89
88
|
setup tables <env> Generate DynamoDB tables from schema
|
|
90
89
|
setup frontend <env> Generate S3 + CloudFront infrastructure
|
|
91
|
-
deploy frontend <env> Build and deploy frontend to AWS
|
|
92
90
|
init [environment] <env> terraform init for environment
|
|
93
91
|
plan [environment] <env> terraform plan for environment
|
|
94
92
|
apply [environment] <env> terraform apply for environment
|
|
@@ -110,8 +108,11 @@ module Belt
|
|
|
110
108
|
belt new blog --frontend react
|
|
111
109
|
belt generate resource post title:string content:text status:string
|
|
112
110
|
belt generate frontend react
|
|
113
|
-
belt
|
|
111
|
+
belt server # Start local frontend server
|
|
112
|
+
belt deploy # Deploy dev to AWS
|
|
113
|
+
belt deploy prod --auto # Deploy prod without confirmation
|
|
114
114
|
belt deploy frontend wups
|
|
115
|
+
belt setup frontend wups
|
|
115
116
|
belt apply wups
|
|
116
117
|
belt tasks # list all rake tasks
|
|
117
118
|
belt lambda:build_layer # run a rake task directly
|