belt 0.2.9 → 0.2.11
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/CHANGELOG.md +119 -0
- data/README.md +217 -8
- data/lib/belt/action_router.rb +33 -5
- data/lib/belt/assets/welcome.css +119 -47
- data/lib/belt/cli/bucket_security.rb +10 -4
- data/lib/belt/cli/deploy_command.rb +48 -0
- data/lib/belt/cli/destroy_command.rb +119 -10
- data/lib/belt/cli/doctor_command.rb +208 -0
- data/lib/belt/cli/environment_command.rb +38 -6
- data/lib/belt/cli/frontend_command.rb +25 -12
- data/lib/belt/cli/frontend_setup_command.rb +41 -0
- data/lib/belt/cli/new_command.rb +106 -38
- data/lib/belt/cli/path_gem_materializer.rb +141 -0
- data/lib/belt/cli/plugin_command.rb +221 -0
- data/lib/belt/cli/setup_command.rb +35 -10
- data/lib/belt/cli.rb +8 -0
- data/lib/belt/configuration.rb +30 -0
- data/lib/belt/controllers/welcome_controller.rb +69 -2
- data/lib/belt/helpers/cors_origin.rb +19 -2
- data/lib/belt/helpers/response.rb +48 -6
- data/lib/belt/http_status.rb +89 -0
- data/lib/belt/lambda_handler.rb +14 -5
- data/lib/belt/rendering.rb +1 -0
- data/lib/belt/version.rb +1 -1
- data/lib/belt/views/welcome/show.html.erb +31 -31
- data/lib/belt.rb +16 -0
- data/lib/belt_controller/base.rb +24 -1
- data/lib/belt_controller/implicit_response.rb +104 -0
- data/lib/templates/environment/backend.tf.erb +1 -1
- data/lib/templates/frontend/react/src/index.css +4 -4
- data/lib/templates/frontend/react/src/lib/apiClient.js.erb +20 -3
- data/lib/templates/frontend/react/src/pages/Home.jsx.erb +283 -4
- data/lib/templates/module/main.tf.erb +13 -3
- data/lib/templates/new_app/config/routes.tf.rb.erb +2 -1
- data/lib/templates/plugin/AGENTS.md.erb +135 -0
- data/lib/templates/plugin/CHANGELOG.md.erb +5 -0
- data/lib/templates/plugin/Gemfile.erb +11 -0
- data/lib/templates/plugin/LICENSE.erb +21 -0
- data/lib/templates/plugin/README.md.erb +63 -0
- data/lib/templates/plugin/Rakefile.erb +7 -0
- data/lib/templates/plugin/gemspec.erb +26 -0
- data/lib/templates/plugin/gitignore.erb +11 -0
- data/lib/templates/plugin/lib/belt/generators/generator.rb.erb +122 -0
- data/lib/templates/plugin/lib/belt/module/configuration.rb.erb +15 -0
- data/lib/templates/plugin/lib/belt/module/version.rb.erb +7 -0
- data/lib/templates/plugin/lib/belt/module.rb.erb +27 -0
- data/lib/templates/plugin/lib/entry.rb.erb +3 -0
- data/lib/templates/plugin/rspec.erb +3 -0
- data/lib/templates/plugin/spec/configuration_spec.rb.erb +17 -0
- data/lib/templates/plugin/spec/spec_helper.rb.erb +19 -0
- metadata +37 -1
data/lib/belt_controller/base.rb
CHANGED
|
@@ -7,11 +7,13 @@ require_relative '../belt/helpers/response'
|
|
|
7
7
|
require_relative '../belt/helpers/error_logging'
|
|
8
8
|
require_relative '../belt/helpers/cors_origin'
|
|
9
9
|
require_relative '../belt/rendering'
|
|
10
|
+
require_relative 'implicit_response'
|
|
10
11
|
|
|
11
12
|
module BeltController
|
|
12
13
|
class Base
|
|
13
14
|
include Belt::Helpers::Response
|
|
14
15
|
include Belt::Rendering
|
|
16
|
+
include ImplicitResponse
|
|
15
17
|
|
|
16
18
|
attr_reader :event, :body
|
|
17
19
|
|
|
@@ -48,6 +50,25 @@ module BeltController
|
|
|
48
50
|
skipped_before_actions << { method: method_name, only: only&.map(&:to_sym), except: except&.map(&:to_sym) }
|
|
49
51
|
end
|
|
50
52
|
|
|
53
|
+
# Implicit response format when the action does not call success_response /
|
|
54
|
+
# error_response / html_response / render / head.
|
|
55
|
+
# Inherits up the controller chain; falls back to Belt.configuration.default_format.
|
|
56
|
+
def default_format
|
|
57
|
+
return @default_format if defined?(@default_format)
|
|
58
|
+
return superclass.default_format if superclass.respond_to?(:default_format)
|
|
59
|
+
|
|
60
|
+
Belt.configuration.default_format
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def default_format=(value)
|
|
64
|
+
format = value.to_sym
|
|
65
|
+
unless Belt::Configuration::VALID_FORMATS.include?(format)
|
|
66
|
+
raise ArgumentError, "default_format must be :json or :html (got #{value.inspect})"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
@default_format = format
|
|
70
|
+
end
|
|
71
|
+
|
|
51
72
|
def all_before_actions
|
|
52
73
|
if superclass.respond_to?(:all_before_actions)
|
|
53
74
|
superclass.all_before_actions + before_actions
|
|
@@ -115,9 +136,11 @@ module BeltController
|
|
|
115
136
|
end
|
|
116
137
|
|
|
117
138
|
run_before_actions(action_sym)
|
|
139
|
+
# Snapshot ivars so we only auto-serialize assigns set by the action (Rails-style)
|
|
140
|
+
@__assigns_before = instance_variables
|
|
118
141
|
result = send(action_sym)
|
|
119
142
|
run_after_actions(action_sym)
|
|
120
|
-
result
|
|
143
|
+
finalize_response(result)
|
|
121
144
|
rescue StandardError => e
|
|
122
145
|
handle_exception(e)
|
|
123
146
|
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BeltController
|
|
4
|
+
# Rails-like implicit JSON/HTML responses from action assigns / return values.
|
|
5
|
+
# Included by BeltController::Base so the base class stays under Metrics/ClassLength.
|
|
6
|
+
module ImplicitResponse
|
|
7
|
+
# Never auto-serialize these into the JSON body (framework internals).
|
|
8
|
+
FRAMEWORK_IVARS = %i[
|
|
9
|
+
@event @raw_body @params @current_action @current_user_id @user_groups
|
|
10
|
+
@logger @__assigns_before @__response_status
|
|
11
|
+
].freeze
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
# Turn the action return value (or instance-variable assigns) into an API Gateway response.
|
|
16
|
+
#
|
|
17
|
+
# Rails-like behavior for API-first controllers (default_format :json):
|
|
18
|
+
# def index
|
|
19
|
+
# @posts = Post.all # → success_response({ posts: [...] })
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# HTML controllers (default_format :html):
|
|
23
|
+
# def show
|
|
24
|
+
# @post = Post.find(...) # → render template views/<ctrl>/show.html.erb
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
# Explicit responses still win:
|
|
28
|
+
# success_response(...), error_response(...), html_response(...), render, head
|
|
29
|
+
#
|
|
30
|
+
# Non-200 with implicit body:
|
|
31
|
+
# def create
|
|
32
|
+
# @post = Post.create!(...)
|
|
33
|
+
# response_status :created
|
|
34
|
+
# end
|
|
35
|
+
def finalize_response(result)
|
|
36
|
+
return result if lambda_response?(result)
|
|
37
|
+
|
|
38
|
+
status = @__response_status || 200
|
|
39
|
+
|
|
40
|
+
return render(status: status) if self.class.default_format == :html
|
|
41
|
+
|
|
42
|
+
# Prefer assigns when present. Ruby assignment returns the RHS, so
|
|
43
|
+
# `@posts = Post.all` would otherwise look like an explicit return value.
|
|
44
|
+
# (Rails ignores the action return value entirely; we keep a thin fallback
|
|
45
|
+
# for explicit non-assign returns.)
|
|
46
|
+
assigns = collect_assigns
|
|
47
|
+
return success_response(assigns, status) if assigns.any?
|
|
48
|
+
|
|
49
|
+
return success_response({}, status) if result.nil?
|
|
50
|
+
|
|
51
|
+
success_response(serialize_for_response(result), status)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def lambda_response?(result)
|
|
55
|
+
result.is_a?(Hash) && (result.key?(:statusCode) || result.key?('statusCode'))
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Instance variables set during the action (excluding framework internals).
|
|
59
|
+
# Keys are the ivar names without @ — so @posts becomes { posts: ... }.
|
|
60
|
+
def collect_assigns
|
|
61
|
+
before = @__assigns_before || []
|
|
62
|
+
(instance_variables - before - FRAMEWORK_IVARS).each_with_object({}) do |ivar, hash|
|
|
63
|
+
key = ivar.to_s.delete_prefix('@')
|
|
64
|
+
next if key.start_with?('_')
|
|
65
|
+
|
|
66
|
+
hash[key.to_sym] = serialize_for_response(instance_variable_get(ivar))
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Deep-serialize models/relations into JSON-friendly hashes/arrays.
|
|
71
|
+
# ActiveItem::Relation (and any Enumerable of models) maps via to_h on each record.
|
|
72
|
+
def serialize_for_response(value)
|
|
73
|
+
case value
|
|
74
|
+
when nil, String, Numeric, TrueClass, FalseClass
|
|
75
|
+
value
|
|
76
|
+
when Symbol
|
|
77
|
+
value.to_s
|
|
78
|
+
when Hash
|
|
79
|
+
value.each_with_object({}) do |(k, v), h|
|
|
80
|
+
h[k.is_a?(Symbol) ? k : k.to_s] = serialize_for_response(v)
|
|
81
|
+
end
|
|
82
|
+
when Array
|
|
83
|
+
value.map { |v| serialize_for_response(v) }
|
|
84
|
+
else
|
|
85
|
+
serialize_object(value)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def serialize_object(value)
|
|
90
|
+
# Relations are Enumerable — map each record (don't call Enumerable#to_h)
|
|
91
|
+
if defined?(ActiveItem::Relation) && value.is_a?(ActiveItem::Relation)
|
|
92
|
+
value.map { |record| serialize_for_response(record) }
|
|
93
|
+
elsif value.respond_to?(:to_h) && !value.is_a?(Enumerable)
|
|
94
|
+
serialize_for_response(value.to_h)
|
|
95
|
+
elsif value.is_a?(Enumerable)
|
|
96
|
+
value.map { |item| serialize_for_response(item) }
|
|
97
|
+
elsif value.respond_to?(:to_h)
|
|
98
|
+
serialize_for_response(value.to_h)
|
|
99
|
+
else
|
|
100
|
+
value
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
:root {
|
|
2
|
-
font-family:
|
|
2
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
3
3
|
line-height: 1.5;
|
|
4
|
-
color: #
|
|
5
|
-
background-color: #
|
|
4
|
+
color: #2d3748;
|
|
5
|
+
background-color: #f8f9fa;
|
|
6
6
|
}
|
|
7
7
|
|
|
8
8
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
9
9
|
|
|
10
|
-
body { min-height: 100vh; }
|
|
10
|
+
body { min-height: 100vh; background: #f8f9fa; }
|
|
@@ -3,15 +3,32 @@ const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000'
|
|
|
3
3
|
export async function apiClient(path, options = {}) {
|
|
4
4
|
const { method = 'GET', body, headers = {} } = options
|
|
5
5
|
|
|
6
|
+
// Only set Content-Type when sending a body. application/json on a GET forces a
|
|
7
|
+
// CORS preflight; Accept alone is safelisted and stays a simple request.
|
|
6
8
|
const config = {
|
|
7
9
|
method,
|
|
8
|
-
headers: {
|
|
10
|
+
headers: {
|
|
11
|
+
Accept: 'application/json',
|
|
12
|
+
...(body !== undefined && body !== null ? { 'Content-Type': 'application/json' } : {}),
|
|
13
|
+
...headers
|
|
14
|
+
}
|
|
9
15
|
}
|
|
10
16
|
|
|
11
|
-
if (body) config.body = JSON.stringify(body)
|
|
17
|
+
if (body !== undefined && body !== null) config.body = JSON.stringify(body)
|
|
12
18
|
|
|
13
19
|
const response = await fetch(`${API_URL}${path}`, config)
|
|
14
|
-
|
|
20
|
+
|
|
21
|
+
let data
|
|
22
|
+
const text = await response.text()
|
|
23
|
+
try {
|
|
24
|
+
data = text ? JSON.parse(text) : {}
|
|
25
|
+
} catch {
|
|
26
|
+
throw new Error(
|
|
27
|
+
response.ok
|
|
28
|
+
? 'API returned non-JSON (is VITE_API_URL pointing at the API Gateway?)'
|
|
29
|
+
: `Request failed: ${response.status}`
|
|
30
|
+
)
|
|
31
|
+
}
|
|
15
32
|
|
|
16
33
|
if (!response.ok) throw new Error(data.error || `Request failed: ${response.status}`)
|
|
17
34
|
|
|
@@ -1,9 +1,288 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import { apiClient } from '../lib/apiClient'
|
|
3
|
+
|
|
4
|
+
// Mirrors lib/belt/assets/welcome.css so SPA and Lambda HTML welcome match.
|
|
5
|
+
// All content lives in the hero overlay — no scroll under the poster.
|
|
6
|
+
const welcomeStyles = `
|
|
7
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
8
|
+
html, body, #root { height: 100%; }
|
|
9
|
+
.belt-welcome {
|
|
10
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
11
|
+
background: #0b1220;
|
|
12
|
+
color: #2d3748;
|
|
13
|
+
min-height: 100vh;
|
|
14
|
+
height: 100%;
|
|
15
|
+
overflow: hidden;
|
|
16
|
+
}
|
|
17
|
+
.belt-welcome .hero {
|
|
18
|
+
position: relative;
|
|
19
|
+
width: 100%;
|
|
20
|
+
height: 100vh;
|
|
21
|
+
overflow: hidden;
|
|
22
|
+
}
|
|
23
|
+
.belt-welcome .hero-bg {
|
|
24
|
+
width: 100%;
|
|
25
|
+
height: 100%;
|
|
26
|
+
object-fit: cover;
|
|
27
|
+
/* Pin the logo / sky — crop hills at the bottom if the viewport is short */
|
|
28
|
+
object-position: top center;
|
|
29
|
+
display: block;
|
|
30
|
+
}
|
|
31
|
+
.belt-welcome .overlay {
|
|
32
|
+
position: absolute;
|
|
33
|
+
top: 50%;
|
|
34
|
+
left: 50%;
|
|
35
|
+
transform: translate(-50%, -50%);
|
|
36
|
+
padding: 1.35rem 1.6rem 1.25rem;
|
|
37
|
+
background: rgba(255, 255, 255, 0.55);
|
|
38
|
+
backdrop-filter: blur(8px);
|
|
39
|
+
-webkit-backdrop-filter: blur(8px);
|
|
40
|
+
border-radius: 14px;
|
|
41
|
+
text-align: center;
|
|
42
|
+
max-width: 560px;
|
|
43
|
+
width: min(92vw, 560px);
|
|
44
|
+
max-height: min(92vh, 620px);
|
|
45
|
+
overflow: auto;
|
|
46
|
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
|
|
47
|
+
}
|
|
48
|
+
.belt-welcome .overlay h1 {
|
|
49
|
+
font-size: 1.85rem;
|
|
50
|
+
font-weight: 700;
|
|
51
|
+
color: #2d3748;
|
|
52
|
+
margin-bottom: 0.3rem;
|
|
53
|
+
line-height: 1.2;
|
|
54
|
+
}
|
|
55
|
+
.belt-welcome .overlay .subtitle {
|
|
56
|
+
font-size: 0.95rem;
|
|
57
|
+
color: #4a5568;
|
|
58
|
+
line-height: 1.4;
|
|
59
|
+
}
|
|
60
|
+
.belt-welcome .stack-check {
|
|
61
|
+
display: flex;
|
|
62
|
+
align-items: center;
|
|
63
|
+
justify-content: center;
|
|
64
|
+
gap: 0.4rem;
|
|
65
|
+
margin: 1rem 0 0.85rem;
|
|
66
|
+
flex-wrap: wrap;
|
|
67
|
+
}
|
|
68
|
+
.belt-welcome .check-item {
|
|
69
|
+
display: flex;
|
|
70
|
+
align-items: center;
|
|
71
|
+
gap: 0.35rem;
|
|
72
|
+
padding: 0.35rem 0.7rem;
|
|
73
|
+
border-radius: 8px;
|
|
74
|
+
font-weight: 500;
|
|
75
|
+
font-size: 0.88rem;
|
|
76
|
+
}
|
|
77
|
+
.belt-welcome .check-pass {
|
|
78
|
+
background: rgba(39, 174, 96, 0.12);
|
|
79
|
+
border: 1px solid rgba(39, 174, 96, 0.35);
|
|
80
|
+
color: #1e8449;
|
|
81
|
+
}
|
|
82
|
+
.belt-welcome .check-neutral {
|
|
83
|
+
background: rgba(113, 128, 150, 0.1);
|
|
84
|
+
border: 1px solid rgba(113, 128, 150, 0.28);
|
|
85
|
+
color: #4a5568;
|
|
86
|
+
}
|
|
87
|
+
.belt-welcome .icon { font-size: 1rem; }
|
|
88
|
+
.belt-welcome .arrow { color: #718096; font-size: 1.05rem; }
|
|
89
|
+
.belt-welcome .next-steps {
|
|
90
|
+
text-align: left;
|
|
91
|
+
border-top: 1px solid rgba(45, 55, 72, 0.12);
|
|
92
|
+
padding-top: 0.75rem;
|
|
93
|
+
margin-top: 0.15rem;
|
|
94
|
+
}
|
|
95
|
+
.belt-welcome .next-steps h2 {
|
|
96
|
+
font-size: 0.92rem;
|
|
97
|
+
margin-bottom: 0.45rem;
|
|
98
|
+
color: #2d3748;
|
|
99
|
+
text-align: left;
|
|
100
|
+
}
|
|
101
|
+
.belt-welcome .next-steps ol {
|
|
102
|
+
padding-left: 1.2rem;
|
|
103
|
+
margin: 0;
|
|
104
|
+
}
|
|
105
|
+
.belt-welcome .next-steps li {
|
|
106
|
+
margin-bottom: 0.4rem;
|
|
107
|
+
color: #4a5568;
|
|
108
|
+
line-height: 1.4;
|
|
109
|
+
font-size: 0.88rem;
|
|
110
|
+
}
|
|
111
|
+
.belt-welcome .next-steps li:last-child { margin-bottom: 0; }
|
|
112
|
+
.belt-welcome .next-steps code {
|
|
113
|
+
background: rgba(99, 45, 145, 0.1);
|
|
114
|
+
padding: 0.12rem 0.35rem;
|
|
115
|
+
border-radius: 4px;
|
|
116
|
+
color: #632d91;
|
|
117
|
+
font-size: 0.78rem;
|
|
118
|
+
word-break: break-word;
|
|
119
|
+
}
|
|
120
|
+
.belt-welcome .footer-note {
|
|
121
|
+
margin-top: 0.75rem;
|
|
122
|
+
padding-top: 0.65rem;
|
|
123
|
+
border-top: 1px solid rgba(45, 55, 72, 0.1);
|
|
124
|
+
font-size: 0.78rem;
|
|
125
|
+
color: #718096;
|
|
126
|
+
text-align: center;
|
|
127
|
+
}
|
|
128
|
+
.belt-welcome .footer-note code {
|
|
129
|
+
background: rgba(45, 55, 72, 0.06);
|
|
130
|
+
padding: 0.08rem 0.3rem;
|
|
131
|
+
border-radius: 3px;
|
|
132
|
+
color: #4a5568;
|
|
133
|
+
}
|
|
134
|
+
.belt-welcome .status-main {
|
|
135
|
+
padding: 3rem 2rem;
|
|
136
|
+
text-align: center;
|
|
137
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
138
|
+
color: #4a5568;
|
|
139
|
+
min-height: 100vh;
|
|
140
|
+
display: flex;
|
|
141
|
+
flex-direction: column;
|
|
142
|
+
align-items: center;
|
|
143
|
+
justify-content: center;
|
|
144
|
+
}
|
|
145
|
+
.belt-welcome .status-main h1 {
|
|
146
|
+
font-size: 1.75rem;
|
|
147
|
+
color: #2d3748;
|
|
148
|
+
margin-bottom: 0.75rem;
|
|
149
|
+
}
|
|
150
|
+
.belt-welcome .error { color: #b91c1c; margin-top: 0.75rem; }
|
|
151
|
+
.belt-welcome .muted { color: #6b7280; margin-top: 0.5rem; font-size: 0.95rem; }
|
|
152
|
+
@media (max-height: 640px) {
|
|
153
|
+
.belt-welcome .overlay { padding: 1rem 1.15rem; }
|
|
154
|
+
.belt-welcome .overlay h1 { font-size: 1.5rem; }
|
|
155
|
+
.belt-welcome .overlay .subtitle { font-size: 0.88rem; }
|
|
156
|
+
.belt-welcome .stack-check { margin: 0.7rem 0 0.55rem; }
|
|
157
|
+
.belt-welcome .check-item { padding: 0.28rem 0.55rem; font-size: 0.8rem; }
|
|
158
|
+
.belt-welcome .next-steps li { font-size: 0.82rem; }
|
|
159
|
+
}
|
|
160
|
+
`
|
|
161
|
+
|
|
162
|
+
function StackItem({ ok, label }) {
|
|
163
|
+
// DynamoDB false is expected (no tables yet) — neutral, not a warning.
|
|
164
|
+
const className = ok ? 'check-item check-pass' : 'check-item check-neutral'
|
|
165
|
+
return (
|
|
166
|
+
<div className={className}>
|
|
167
|
+
<span className="icon" aria-hidden="true">{ok ? '✓' : '○'}</span>
|
|
168
|
+
<span>{label}</span>
|
|
169
|
+
</div>
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Turn "Label: belt some command" into mixed text + <code> when possible.
|
|
174
|
+
function StepLine({ step }) {
|
|
175
|
+
const match = typeof step === 'string' ? step.match(/^(.*?):\s+(belt\s.+)$/) : null
|
|
176
|
+
if (match) {
|
|
177
|
+
return (
|
|
178
|
+
<>
|
|
179
|
+
{match[1]}: <code>{match[2]}</code>
|
|
180
|
+
</>
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
return step
|
|
184
|
+
}
|
|
185
|
+
|
|
1
186
|
function Home() {
|
|
187
|
+
const [welcome, setWelcome] = useState(null)
|
|
188
|
+
const [error, setError] = useState(null)
|
|
189
|
+
const [loading, setLoading] = useState(true)
|
|
190
|
+
|
|
191
|
+
useEffect(() => {
|
|
192
|
+
let cancelled = false
|
|
193
|
+
|
|
194
|
+
apiClient('/')
|
|
195
|
+
.then((data) => {
|
|
196
|
+
if (!cancelled) {
|
|
197
|
+
setWelcome(data)
|
|
198
|
+
setLoading(false)
|
|
199
|
+
}
|
|
200
|
+
})
|
|
201
|
+
.catch((err) => {
|
|
202
|
+
if (!cancelled) {
|
|
203
|
+
setError(err.message || 'Failed to reach API')
|
|
204
|
+
setLoading(false)
|
|
205
|
+
}
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
return () => { cancelled = true }
|
|
209
|
+
}, [])
|
|
210
|
+
|
|
211
|
+
if (loading) {
|
|
212
|
+
return (
|
|
213
|
+
<div className="belt-welcome">
|
|
214
|
+
<style>{welcomeStyles}</style>
|
|
215
|
+
<main className="status-main">
|
|
216
|
+
<p className="muted">Connecting to API…</p>
|
|
217
|
+
</main>
|
|
218
|
+
</div>
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (error) {
|
|
223
|
+
return (
|
|
224
|
+
<div className="belt-welcome">
|
|
225
|
+
<style>{welcomeStyles}</style>
|
|
226
|
+
<main className="status-main">
|
|
227
|
+
<h1><%= @module_name %></h1>
|
|
228
|
+
<p className="error">Could not load welcome data from the API.</p>
|
|
229
|
+
<p className="muted">{error}</p>
|
|
230
|
+
<p className="muted" style={{ marginTop: '1rem' }}>
|
|
231
|
+
Check <code>VITE_API_URL</code> (run <code>belt frontend env dev</code>) and that CORS allows this origin.
|
|
232
|
+
</p>
|
|
233
|
+
</main>
|
|
234
|
+
</div>
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const stack = welcome.stack || {}
|
|
239
|
+
const dynamoOk = !!stack.dynamodb
|
|
240
|
+
const title = welcome.title || '<%= @module_name %>'
|
|
241
|
+
const subtitle = welcome.subtitle || 'Your Belt app is running.'
|
|
242
|
+
|
|
2
243
|
return (
|
|
3
|
-
<
|
|
4
|
-
<
|
|
5
|
-
|
|
6
|
-
|
|
244
|
+
<div className="belt-welcome">
|
|
245
|
+
<style>{welcomeStyles}</style>
|
|
246
|
+
|
|
247
|
+
<div className="hero">
|
|
248
|
+
{welcome.background_image ? (
|
|
249
|
+
<img
|
|
250
|
+
src={welcome.background_image}
|
|
251
|
+
alt="Belt"
|
|
252
|
+
className="hero-bg"
|
|
253
|
+
/>
|
|
254
|
+
) : (
|
|
255
|
+
<div className="hero-bg" style={{ minHeight: '100vh', background: '#1a202c' }} />
|
|
256
|
+
)}
|
|
257
|
+
<div className="overlay">
|
|
258
|
+
<h1>{title}</h1>
|
|
259
|
+
<p className="subtitle">{subtitle}</p>
|
|
260
|
+
|
|
261
|
+
<div className="stack-check">
|
|
262
|
+
<StackItem ok={!!stack.api_gateway} label="API Gateway" />
|
|
263
|
+
<span className="arrow">→</span>
|
|
264
|
+
<StackItem ok={!!stack.lambda} label="Lambda" />
|
|
265
|
+
<span className="arrow">→</span>
|
|
266
|
+
<StackItem ok={dynamoOk} label="DynamoDB" />
|
|
267
|
+
</div>
|
|
268
|
+
|
|
269
|
+
{Array.isArray(welcome.next_steps) && welcome.next_steps.length > 0 && (
|
|
270
|
+
<div className="next-steps">
|
|
271
|
+
<h2>Next Steps</h2>
|
|
272
|
+
<ol>
|
|
273
|
+
{welcome.next_steps.map((step, i) => (
|
|
274
|
+
<li key={i}><StepLine step={step} /></li>
|
|
275
|
+
))}
|
|
276
|
+
</ol>
|
|
277
|
+
</div>
|
|
278
|
+
)}
|
|
279
|
+
|
|
280
|
+
<p className="footer-note">
|
|
281
|
+
Edit <code>src/pages/Home.jsx</code> to replace this page.
|
|
282
|
+
</p>
|
|
283
|
+
</div>
|
|
284
|
+
</div>
|
|
285
|
+
</div>
|
|
7
286
|
)
|
|
8
287
|
}
|
|
9
288
|
|
|
@@ -21,17 +21,27 @@ terraform {
|
|
|
21
21
|
resource "conveyor_belt" "main" {
|
|
22
22
|
provider = conveyor-belt
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
# path.module is infrastructure/modules/app — climb three levels to project root
|
|
25
|
+
source = "${path.module}/../../../config/routes.tf.rb"
|
|
25
26
|
app_name = var.app_name
|
|
26
27
|
lambda_source_dir = "${path.module}/../../../lambda"
|
|
27
28
|
lambda_shared_dirs = ["controllers", "helpers", "lib", "models", "views"]
|
|
28
|
-
|
|
29
|
+
<% if @frontend -%>
|
|
30
|
+
# CloudFront first so SPA→API CORS works out of the box (tutorial footgun fixed).
|
|
31
|
+
# localhost stays via env frontend_urls for local `belt server` dev.
|
|
32
|
+
frontend_urls = concat(
|
|
33
|
+
["https://${aws_cloudfront_distribution.frontend.domain_name}"],
|
|
34
|
+
var.frontend_urls
|
|
35
|
+
)
|
|
36
|
+
<% else -%>
|
|
37
|
+
frontend_urls = var.frontend_urls
|
|
38
|
+
<% end -%>
|
|
29
39
|
friendly_errors = var.environment != "prod"
|
|
30
40
|
|
|
31
41
|
custom_domain_name = local.dns_enabled ? local.api_domain : ""
|
|
32
42
|
|
|
33
43
|
# Per-lambda configuration from config/lambda/*.yml (database.yml style)
|
|
34
|
-
lambda_config_dir = "${path.module}
|
|
44
|
+
lambda_config_dir = "${path.module}/../../../config/lambda"
|
|
35
45
|
|
|
36
46
|
# Dynamic value references — YAML env_vars use ref(name) to pull these in
|
|
37
47
|
lambda_env_refs = var.lambda_env_refs
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
Belt.application.routes.draw do
|
|
2
2
|
namespace :api do
|
|
3
|
-
|
|
3
|
+
# Public stack-check / welcome (HTML for browsers, JSON for the SPA shell)
|
|
4
|
+
get "/", action: :show, controller: :welcome, auth: :none
|
|
4
5
|
# resources :posts
|
|
5
6
|
end
|
|
6
7
|
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# AGENTS.md — <%= gem_name %>
|
|
2
|
+
|
|
3
|
+
This file is for AI agents (and humans) working **on this Belt plugin gem**, not on a host Belt app.
|
|
4
|
+
|
|
5
|
+
Host apps get their own `AGENTS.md` from `belt new`. Core belt docs live in the [belt](https://github.com/stowzilla/belt) repo (`README.md` + root `AGENTS.md`).
|
|
6
|
+
|
|
7
|
+
## What this gem is
|
|
8
|
+
|
|
9
|
+
**<%= gem_name %>** is a Belt plugin: optional capability that installs into Belt apps via the generator API.
|
|
10
|
+
|
|
11
|
+
- **Runtime API** lives in the gem (`<%= constant_path %>`) — apps require the gem and call it directly
|
|
12
|
+
- **Generator** copies only what the host app must own (Terraform, Lambda entrypoints, optional overrides)
|
|
13
|
+
- After the gem is in an app's Gemfile, `belt generate <%= plugin_name %>` / `belt destroy <%= plugin_name %>` work automatically
|
|
14
|
+
|
|
15
|
+
## Stack
|
|
16
|
+
|
|
17
|
+
| Piece | Role |
|
|
18
|
+
|-------|------|
|
|
19
|
+
| **belt** | Host framework + CLI; discovers this generator via `GeneratorRegistry` |
|
|
20
|
+
| **<%= gem_name %>** (this repo) | Plugin runtime + `belt generate <%= plugin_name %>` |
|
|
21
|
+
| Host Belt app | Where Terraform / Lambda config land after generation |
|
|
22
|
+
|
|
23
|
+
## Layout
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
<%= gem_name %>/
|
|
27
|
+
├── <%= gem_name %>.gemspec
|
|
28
|
+
├── lib/
|
|
29
|
+
│ ├── <%= gem_name %>.rb # require entrypoint
|
|
30
|
+
│ └── belt/
|
|
31
|
+
│ ├── <%= underscored %>.rb # <%= constant_path %> API
|
|
32
|
+
│ ├── <%= underscored %>/
|
|
33
|
+
│ │ ├── configuration.rb
|
|
34
|
+
│ │ ├── version.rb
|
|
35
|
+
│ │ └── templates/ # ERB used by the generator
|
|
36
|
+
│ └── generators/
|
|
37
|
+
│ └── <%= underscored %>_generator.rb # ← auto-discovered
|
|
38
|
+
├── spec/
|
|
39
|
+
├── README.md
|
|
40
|
+
└── AGENTS.md # This file
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
**Do not rename** `lib/belt/generators/<%= underscored %>_generator.rb` or the class
|
|
44
|
+
`Belt::Generators::<%= generator_class %>` — discovery depends on that path and name.
|
|
45
|
+
|
|
46
|
+
## Generator contract
|
|
47
|
+
|
|
48
|
+
Implemented in `lib/belt/generators/<%= underscored %>_generator.rb`:
|
|
49
|
+
|
|
50
|
+
| Method | Required? | Purpose |
|
|
51
|
+
|--------|-----------|---------|
|
|
52
|
+
| `.run(args)` | **Yes** | Install into the current Belt app |
|
|
53
|
+
| `.destroy(args)` | Recommended | Tear down what generate created |
|
|
54
|
+
| `.description` | Recommended | One-liner for `belt generate --help` |
|
|
55
|
+
|
|
56
|
+
Class must live in `Belt::Generators` and be named `<%= generator_class %>`.
|
|
57
|
+
|
|
58
|
+
Reference implementations (copy structure, don't invent a parallel one):
|
|
59
|
+
|
|
60
|
+
- `belt-messaging` — SMS via AWS End User Messaging
|
|
61
|
+
- `belt-pay` — Stripe payments & subscriptions
|
|
62
|
+
|
|
63
|
+
## Generator checklist
|
|
64
|
+
|
|
65
|
+
When fleshing out the generator, typically install:
|
|
66
|
+
|
|
67
|
+
1. **Terraform module** → `infrastructure/modules/<%= plugin_name %>/` (`main.tf`, `variables.tf`, `outputs.tf`)
|
|
68
|
+
2. **Lambda config** → `config/lambda/<%= plugin_name %>.yml` (timeout, memory, env, triggers)
|
|
69
|
+
3. **Lambda entrypoint** → `lambda/<%= plugin_name %>.rb` using `Belt::LambdaHandler`
|
|
70
|
+
4. **Routes / schema** → inject into `config/routes.tf.rb` or schema files when needed
|
|
71
|
+
5. **Optional overrides** → `--controllers` (or similar) for app-local subclasses — keep defaults in the gem
|
|
72
|
+
6. **Destroy path** → `belt destroy <%= plugin_name %>` removes generated artifacts
|
|
73
|
+
7. **Help** → `.description` plus `--help` / `-h` explaining files and next steps
|
|
74
|
+
|
|
75
|
+
**Principle:** runtime stays in the gem; generators copy only host-owned infra and optional overrides (same idea as `rails g devise:views`).
|
|
76
|
+
|
|
77
|
+
## Local development
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
bundle install
|
|
81
|
+
bundle exec rspec
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Wire into a Belt app while iterating:
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
# In the app Gemfile
|
|
88
|
+
gem "<%= gem_name %>", path: "../<%= gem_name %>"
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
cd /path/to/belt-app
|
|
93
|
+
bundle install
|
|
94
|
+
belt generate --help # should list "<%= plugin_name %>" under Gem Generators
|
|
95
|
+
belt generate <%= plugin_name %>
|
|
96
|
+
belt destroy <%= plugin_name %>
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`belt deploy` vendors `path:` gems into `vendor/cache` so Lambda packaging works without publishing.
|
|
100
|
+
|
|
101
|
+
## Configuration pattern
|
|
102
|
+
|
|
103
|
+
```ruby
|
|
104
|
+
<%= constant_path %>.configure do |config|
|
|
105
|
+
# config.option = value
|
|
106
|
+
end
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Extend `lib/belt/<%= underscored %>/configuration.rb` and document options in README.
|
|
110
|
+
|
|
111
|
+
## Conventions
|
|
112
|
+
|
|
113
|
+
1. Follow patterns from `belt-messaging` / `belt-pay` before inventing new structure
|
|
114
|
+
2. Keep the generator idempotent where practical; support `--force` for overwrites
|
|
115
|
+
3. Specs under `spec/` for configuration and any non-trivial generator logic
|
|
116
|
+
4. User-facing changes → `CHANGELOG.md`
|
|
117
|
+
5. Never commit secrets, API keys, or real AWS account IDs
|
|
118
|
+
6. Prefer gem defaults + optional generate flags over dumping all code into the app
|
|
119
|
+
|
|
120
|
+
## Where to look first
|
|
121
|
+
|
|
122
|
+
| Task | Start here |
|
|
123
|
+
|------|------------|
|
|
124
|
+
| Runtime API | `lib/belt/<%= underscored %>.rb`, `lib/belt/<%= underscored %>/` |
|
|
125
|
+
| What gets installed into apps | `lib/belt/generators/<%= underscored %>_generator.rb` |
|
|
126
|
+
| Generator ERB templates | `lib/belt/<%= underscored %>/templates/` |
|
|
127
|
+
| Version / gemspec | `lib/belt/<%= underscored %>/version.rb`, `<%= gem_name %>.gemspec` |
|
|
128
|
+
| How belt discovers this | belt gem: `Belt::CLI::GeneratorRegistry` |
|
|
129
|
+
|
|
130
|
+
## Do not
|
|
131
|
+
|
|
132
|
+
- Move the generator file out of `lib/belt/generators/*_generator.rb`
|
|
133
|
+
- Require a manual initializer in the host app for registration — discovery is automatic
|
|
134
|
+
- Put host-app deploy runbooks here — those belong in the app's `AGENTS.md`
|
|
135
|
+
- Duplicate large runtime classes into generated app files unless the user opts in (e.g. `--controllers`)
|