belt 0.1.4 → 0.1.6
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.jpg +0 -0
- data/lib/belt/assets/welcome.css +112 -0
- data/lib/belt/cli/app_detection.rb +17 -1
- data/lib/belt/cli/deploy_command.rb +495 -0
- data/lib/belt/cli/environment_command.rb +12 -7
- data/lib/belt/cli/generate_command.rb +165 -10
- data/lib/belt/cli/new_command.rb +168 -25
- 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 +46 -0
- data/lib/belt/rendering.rb +136 -0
- data/lib/belt/version.rb +1 -1
- data/lib/belt/views/welcome/show.html.erb +51 -0
- data/lib/belt.rb +2 -0
- data/lib/belt_controller/base.rb +2 -0
- data/lib/templates/environment/main.tf.erb +17 -3
- data/lib/templates/environment/outputs.tf.erb +9 -0
- data/lib/templates/new_app/infrastructure/routes.tf.rb.erb +2 -1
- data/lib/templates/new_app/lambda/api.rb.erb +2 -2
- data/lib/templates/new_app/lambda/controllers/application_controller.rb.erb +1 -1
- data/lib/templates/new_app/lambda/lib/routes/routes.rb.erb +2 -1
- metadata +9 -1
|
@@ -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.jpg') || ''
|
|
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/jpeg;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/jpeg;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
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'base64'
|
|
4
|
+
|
|
5
|
+
module Belt
|
|
6
|
+
# Gem-embedded welcome controller that serves the default "it works!" page.
|
|
7
|
+
# Resolved via the ActionRouter's Belt:: namespace fallback when no app-defined
|
|
8
|
+
# WelcomeController exists. Gets replaced once the user defines their own root route.
|
|
9
|
+
class WelcomeController < BeltController::Base
|
|
10
|
+
skip_before_action :authenticate!
|
|
11
|
+
|
|
12
|
+
ASSETS_DIR = File.expand_path('../assets', __dir__)
|
|
13
|
+
|
|
14
|
+
def show
|
|
15
|
+
@title = ENV.fetch('WELCOME_TITLE', 'Welcome to Belt')
|
|
16
|
+
@subtitle = ENV.fetch('WELCOME_SUBTITLE', 'API Gateway → Lambda → DynamoDB — all connected.')
|
|
17
|
+
@css = welcome_css
|
|
18
|
+
@background_image = background_image_base64
|
|
19
|
+
@dynamodb_connected = dynamodb_connected?
|
|
20
|
+
|
|
21
|
+
render
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def dynamodb_connected?
|
|
27
|
+
return false unless defined?(Aws::DynamoDB::Client)
|
|
28
|
+
|
|
29
|
+
client = Aws::DynamoDB::Client.new
|
|
30
|
+
client.list_tables(limit: 1)
|
|
31
|
+
true
|
|
32
|
+
rescue StandardError
|
|
33
|
+
false
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def welcome_css
|
|
37
|
+
@welcome_css_content ||= File.read(File.join(ASSETS_DIR, 'welcome.css'))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def background_image_base64
|
|
41
|
+
@background_image_content ||= Base64.strict_encode64(
|
|
42
|
+
File.binread(File.join(ASSETS_DIR, 'belt-default.jpg'))
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'erb'
|
|
4
|
+
require 'cgi'
|
|
5
|
+
|
|
6
|
+
module Belt
|
|
7
|
+
# Provides Rails-like ERB rendering for BeltController.
|
|
8
|
+
#
|
|
9
|
+
# Convention: templates live at `views/<controller_name>/<action>.html.erb`
|
|
10
|
+
# relative to the controller's gem or app directory.
|
|
11
|
+
#
|
|
12
|
+
# Usage:
|
|
13
|
+
# class WelcomeController < BeltController::Base
|
|
14
|
+
# def show
|
|
15
|
+
# @title = "Hello"
|
|
16
|
+
# render # auto-resolves to views/welcome/show.html.erb
|
|
17
|
+
# end
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# Or explicit:
|
|
21
|
+
# render template: "welcome/show"
|
|
22
|
+
# render inline: "<h1><%= @title %></h1>"
|
|
23
|
+
#
|
|
24
|
+
module Rendering
|
|
25
|
+
def self.included(base)
|
|
26
|
+
base.extend(ClassMethods)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
module ClassMethods
|
|
30
|
+
# Override in subclasses to set a custom view path.
|
|
31
|
+
# Defaults to `views/` relative to the file that defines the controller.
|
|
32
|
+
def view_paths
|
|
33
|
+
@view_paths ||= []
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def prepend_view_path(path)
|
|
37
|
+
view_paths.unshift(path)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def append_view_path(path)
|
|
41
|
+
view_paths << path
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# Render an ERB template and return an html_response.
|
|
48
|
+
#
|
|
49
|
+
# Options:
|
|
50
|
+
# render — auto-resolve from controller/action
|
|
51
|
+
# render template: "ctrl/action" — explicit template path (no extension)
|
|
52
|
+
# render inline: "<%= @x %>" — render a string as ERB
|
|
53
|
+
# render status: 201 — custom status code
|
|
54
|
+
# render layout: false — skip layout (default: no layout)
|
|
55
|
+
#
|
|
56
|
+
def render(options = {})
|
|
57
|
+
status = options.fetch(:status, 200)
|
|
58
|
+
|
|
59
|
+
html = if options.key?(:inline)
|
|
60
|
+
render_erb_string(options[:inline])
|
|
61
|
+
else
|
|
62
|
+
template_path = resolve_template_path(options[:template])
|
|
63
|
+
render_erb_file(template_path)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
html_response(html, status)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def render_erb_string(source)
|
|
70
|
+
erb = ERB.new(source, trim_mode: '-')
|
|
71
|
+
erb.result(binding)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def render_erb_file(path)
|
|
75
|
+
raise Belt::TemplateNotFound, "Template not found: #{path}" unless File.exist?(path)
|
|
76
|
+
|
|
77
|
+
source = File.read(path)
|
|
78
|
+
erb = ERB.new(source, trim_mode: '-')
|
|
79
|
+
erb.filename = path
|
|
80
|
+
erb.result(binding)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# HTML-escape helper, available in templates as `h(value)`
|
|
84
|
+
def h(text)
|
|
85
|
+
CGI.escapeHTML(text.to_s)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def resolve_template_path(explicit_template = nil)
|
|
89
|
+
template_name = explicit_template || default_template_name
|
|
90
|
+
search_paths = build_view_search_paths
|
|
91
|
+
|
|
92
|
+
search_paths.each do |base|
|
|
93
|
+
full_path = File.join(base, "#{template_name}.html.erb")
|
|
94
|
+
return full_path if File.exist?(full_path)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# If nothing found, return the first candidate so the error message is useful
|
|
98
|
+
File.join(search_paths.first || '.', "#{template_name}.html.erb")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def default_template_name
|
|
102
|
+
# PostsController → posts, Belt::WelcomeController → welcome
|
|
103
|
+
controller = self.class.name.split('::').last
|
|
104
|
+
.sub(/Controller$/, '')
|
|
105
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
106
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
107
|
+
.downcase
|
|
108
|
+
"#{controller}/#{action_name}"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def build_view_search_paths
|
|
112
|
+
paths = []
|
|
113
|
+
|
|
114
|
+
# 1. Class-level view paths (set by the controller or gem)
|
|
115
|
+
paths.concat(self.class.view_paths) if self.class.respond_to?(:view_paths)
|
|
116
|
+
|
|
117
|
+
# 2. Walk up the class hierarchy for inherited view paths
|
|
118
|
+
klass = self.class.superclass
|
|
119
|
+
while klass.respond_to?(:view_paths)
|
|
120
|
+
paths.concat(klass.view_paths)
|
|
121
|
+
klass = klass.superclass
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# 3. App-level views (convention: lambda/views/ from Belt.root)
|
|
125
|
+
if defined?(Belt.root) && Belt.root
|
|
126
|
+
paths << File.join(Belt.root, 'lambda', 'views')
|
|
127
|
+
paths << File.join(Belt.root, 'views')
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# 4. Gem-level fallback (views/ relative to this file — belt gem's own views)
|
|
131
|
+
paths << File.expand_path('views', __dir__)
|
|
132
|
+
|
|
133
|
+
paths.uniq
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
data/lib/belt/version.rb
CHANGED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title><%= h(@title) %></title>
|
|
7
|
+
<style><%= @css %></style>
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div class="hero">
|
|
11
|
+
<img src="data:image/jpeg;base64,<%= @background_image %>" alt="Belt" class="hero-bg" />
|
|
12
|
+
<div class="overlay">
|
|
13
|
+
<h1><%= h(@title) %></h1>
|
|
14
|
+
<p class="subtitle"><%= h(@subtitle) %></p>
|
|
15
|
+
</div>
|
|
16
|
+
</div>
|
|
17
|
+
<div class="container">
|
|
18
|
+
<div class="stack-check">
|
|
19
|
+
<div class="check-item check-pass">
|
|
20
|
+
<span class="icon">✓</span>
|
|
21
|
+
<span>API Gateway</span>
|
|
22
|
+
</div>
|
|
23
|
+
<div class="arrow">→</div>
|
|
24
|
+
<div class="check-item check-pass">
|
|
25
|
+
<span class="icon">✓</span>
|
|
26
|
+
<span>Lambda</span>
|
|
27
|
+
</div>
|
|
28
|
+
<div class="arrow">→</div>
|
|
29
|
+
<%- if @dynamodb_connected -%>
|
|
30
|
+
<div class="check-item check-pass">
|
|
31
|
+
<span class="icon">✓</span>
|
|
32
|
+
<span>DynamoDB</span>
|
|
33
|
+
</div>
|
|
34
|
+
<%- else -%>
|
|
35
|
+
<div class="check-item check-warn">
|
|
36
|
+
<span class="icon">⚠</span>
|
|
37
|
+
<span>DynamoDB</span>
|
|
38
|
+
</div>
|
|
39
|
+
<%- end -%>
|
|
40
|
+
</div>
|
|
41
|
+
<div class="next-steps">
|
|
42
|
+
<h2>Next Steps</h2>
|
|
43
|
+
<ol>
|
|
44
|
+
<li>Generate a resource: <code>belt g resource post title:string body:text</code></li>
|
|
45
|
+
<li>Deploy: <code>belt deploy</code></li>
|
|
46
|
+
<li>This page will be replaced once you define your own root route.</li>
|
|
47
|
+
</ol>
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
</body>
|
|
51
|
+
</html>
|
data/lib/belt.rb
CHANGED
|
@@ -11,6 +11,7 @@ module Belt
|
|
|
11
11
|
class AuthenticationError < StandardError; end
|
|
12
12
|
class RecordNotFound < StandardError; end
|
|
13
13
|
class ActionNotFound < StandardError; end
|
|
14
|
+
class TemplateNotFound < StandardError; end
|
|
14
15
|
|
|
15
16
|
@controller_paths = []
|
|
16
17
|
|
|
@@ -55,3 +56,4 @@ module Belt
|
|
|
55
56
|
end
|
|
56
57
|
|
|
57
58
|
require_relative 'belt_controller/base'
|
|
59
|
+
require_relative 'belt/controllers/welcome_controller'
|