belt 0.2.17 → 0.3.15
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 +241 -0
- data/README.md +42 -3
- data/lib/belt/action_router.rb +34 -19
- data/lib/belt/cli/app_detection.rb +30 -3
- data/lib/belt/cli/auth_command.rb +395 -0
- data/lib/belt/cli/console_command.rb +17 -2
- data/lib/belt/cli/deploy_command.rb +51 -17
- data/lib/belt/cli/destroy_command.rb +62 -25
- data/lib/belt/cli/doctor_command.rb +166 -22
- data/lib/belt/cli/environment_command.rb +12 -0
- data/lib/belt/cli/explain_command.rb +112 -0
- data/lib/belt/cli/frontend_command.rb +31 -10
- data/lib/belt/cli/frontend_deploy_command.rb +110 -21
- data/lib/belt/cli/frontend_env_command.rb +56 -19
- data/lib/belt/cli/frontend_env_map.rb +30 -10
- data/lib/belt/cli/frontend_registry.rb +373 -0
- data/lib/belt/cli/frontend_setup_command.rb +71 -5
- data/lib/belt/cli/generate_command.rb +61 -42
- data/lib/belt/cli/index_command.rb +192 -0
- data/lib/belt/cli/lambda_config_command.rb +9 -3
- data/lib/belt/cli/routes_command/request_model_inference.rb +131 -0
- data/lib/belt/cli/routes_command/route_inference.rb +4 -3
- data/lib/belt/cli/routes_command.rb +31 -19
- data/lib/belt/cli/server_command.rb +28 -15
- data/lib/belt/cli/tables_command.rb +3 -1
- data/lib/belt/cli/terraform_command.rb +9 -0
- data/lib/belt/cli/views_command.rb +15 -15
- data/lib/belt/cli/zip_artifact_builder.rb +199 -0
- data/lib/belt/cli.rb +17 -9
- data/lib/belt/docs/backups.md +101 -0
- data/lib/belt/docs/console.md +65 -0
- data/lib/belt/docs/controllers.md +155 -0
- data/lib/belt/docs/deployment.md +166 -0
- data/lib/belt/docs/frontend.md +103 -0
- data/lib/belt/docs/generators.md +137 -0
- data/lib/belt/docs/lambda_handler.md +105 -0
- data/lib/belt/docs/models.md +160 -0
- data/lib/belt/docs/observability.md +94 -0
- data/lib/belt/docs/plugins.md +94 -0
- data/lib/belt/docs/routing.md +138 -0
- data/lib/belt/docs/structure.md +96 -0
- data/lib/belt/inflector.rb +8 -0
- data/lib/belt/route_dsl.rb +318 -100
- data/lib/belt/version.rb +1 -1
- data/lib/templates/frontend/react/env.yml.example +2 -2
- data/lib/templates/generate/auth/cognito.tf.erb +71 -0
- data/lib/templates/generate/auth/cognito_outputs.tf.erb +19 -0
- data/lib/templates/generate/auth/frontend/ConfirmEmail.jsx +55 -0
- data/lib/templates/generate/auth/frontend/Login.jsx +79 -0
- data/lib/templates/generate/auth/frontend/ProtectedRoute.jsx +9 -0
- data/lib/templates/generate/auth/frontend/SignUp.jsx +58 -0
- data/lib/templates/generate/auth/frontend/apiClient.js +34 -0
- data/lib/templates/generate/auth/frontend/auth.css +157 -0
- data/lib/templates/generate/auth/frontend/auth.js +134 -0
- data/lib/templates/generate/model.rb.erb +4 -6
- data/lib/templates/module/frontend.tf.erb +49 -43
- data/lib/templates/module/outputs.tf.erb +1 -1
- data/lib/templates/new_app/AGENTS.md.erb +170 -20
- data/lib/templates/new_app/config/lambda/api.yml.erb +1 -0
- data/lib/templates/new_app/config/routes.rb.erb +8 -3
- data/lib/templates/new_app/lambda/api.rb.erb +1 -1
- metadata +56 -1
|
@@ -7,12 +7,14 @@ require_relative '../route_dsl'
|
|
|
7
7
|
require_relative '../table_inference'
|
|
8
8
|
require_relative 'routes_command/schema_loader'
|
|
9
9
|
require_relative 'routes_command/route_inference'
|
|
10
|
+
require_relative 'routes_command/request_model_inference'
|
|
10
11
|
|
|
11
12
|
module Belt
|
|
12
13
|
module CLI
|
|
13
14
|
class RoutesCommand
|
|
14
15
|
include SchemaLoader
|
|
15
16
|
include RouteInference
|
|
17
|
+
include RequestModelInference
|
|
16
18
|
|
|
17
19
|
def self.run(args)
|
|
18
20
|
new(args).run
|
|
@@ -33,6 +35,11 @@ module Belt
|
|
|
33
35
|
dsl = load_routes(routes_file)
|
|
34
36
|
@table_inference = TableInference.new(@options[:tables_file])
|
|
35
37
|
routes = collect_routes(dsl)
|
|
38
|
+
|
|
39
|
+
# Convention-based request_model inference from contracts
|
|
40
|
+
contracts_file = resolve_contracts_file(routes_file)
|
|
41
|
+
infer_request_models!(routes, contracts_file) if contracts_file
|
|
42
|
+
|
|
36
43
|
routes = apply_grep(routes) if @options[:grep]
|
|
37
44
|
|
|
38
45
|
warn 'Warning: --output-dir has no effect without --namespace' if @options[:output_dir] && !@options[:namespace]
|
|
@@ -114,10 +121,34 @@ module Belt
|
|
|
114
121
|
gateway.routes.each do |route|
|
|
115
122
|
routes << build_route_hash(route, gateway)
|
|
116
123
|
end
|
|
124
|
+
inject_welcome_route!(routes, gateway) unless root_route?(gateway)
|
|
117
125
|
end
|
|
118
126
|
routes.sort_by { |r| route_specificity(r[:path], r[:verb]) }
|
|
119
127
|
end
|
|
120
128
|
|
|
129
|
+
# Ensure every gateway has a GET / route so the API Gateway resource is created.
|
|
130
|
+
# Without this, visiting the root URL returns a 404 at the gateway level before
|
|
131
|
+
# the request ever reaches Lambda.
|
|
132
|
+
def root_route?(gateway)
|
|
133
|
+
gateway.routes.any? { |r| r.method == 'GET' && normalize_path(r.path) == '/' }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def inject_welcome_route!(routes, gateway)
|
|
137
|
+
routes << {
|
|
138
|
+
name: 'root',
|
|
139
|
+
verb: 'GET',
|
|
140
|
+
path: '/',
|
|
141
|
+
gateway: gateway.name,
|
|
142
|
+
lambda: gateway.default_lambda.to_s,
|
|
143
|
+
controller: 'welcome',
|
|
144
|
+
action: 'show',
|
|
145
|
+
auth: 'none',
|
|
146
|
+
tables: [],
|
|
147
|
+
request_model: '',
|
|
148
|
+
response_model: ''
|
|
149
|
+
}
|
|
150
|
+
end
|
|
151
|
+
|
|
121
152
|
def build_route_hash(route, gateway)
|
|
122
153
|
hash = {
|
|
123
154
|
name: extract_route_name(route.path),
|
|
@@ -250,18 +281,8 @@ module Belt
|
|
|
250
281
|
def output_concise(routes)
|
|
251
282
|
return puts('No routes defined.') if routes.empty?
|
|
252
283
|
|
|
253
|
-
multi_gateway = routes.map { |r| r[:gateway] }.uniq.length > 1
|
|
254
284
|
verb_w = [routes.map { |r| r[:verb].length }.max, 6].max
|
|
255
285
|
path_w = [routes.map { |r| r[:path].length }.max, 4].max
|
|
256
|
-
|
|
257
|
-
if multi_gateway
|
|
258
|
-
output_concise_multi_gateway(routes, verb_w, path_w)
|
|
259
|
-
else
|
|
260
|
-
output_concise_single_gateway(routes, verb_w, path_w)
|
|
261
|
-
end
|
|
262
|
-
end
|
|
263
|
-
|
|
264
|
-
def output_concise_multi_gateway(routes, verb_w, path_w)
|
|
265
286
|
gw_w = [routes.map { |r| r[:gateway].to_s.length }.max, 7].max
|
|
266
287
|
lam_w = [routes.map { |r| r[:lambda].length }.max, 6].max
|
|
267
288
|
|
|
@@ -278,15 +299,6 @@ module Belt
|
|
|
278
299
|
end
|
|
279
300
|
end
|
|
280
301
|
|
|
281
|
-
def output_concise_single_gateway(routes, verb_w, path_w)
|
|
282
|
-
puts "#{'VERB'.ljust(verb_w)} #{'PATH'.ljust(path_w)} CONTROLLER#ACTION"
|
|
283
|
-
puts '-' * (verb_w + path_w + 30)
|
|
284
|
-
|
|
285
|
-
routes.each do |r|
|
|
286
|
-
puts "#{r[:verb].ljust(verb_w)} #{r[:path].ljust(path_w)} #{r[:controller]}##{r[:action]}"
|
|
287
|
-
end
|
|
288
|
-
end
|
|
289
|
-
|
|
290
302
|
def route_specificity(path, verb)
|
|
291
303
|
segments = path.split('/').reject(&:empty?)
|
|
292
304
|
param_count = segments.count { |s| s.start_with?('{') }
|
|
@@ -4,6 +4,7 @@ require 'base64'
|
|
|
4
4
|
require 'json'
|
|
5
5
|
require_relative 'app_detection'
|
|
6
6
|
require_relative 'frontend_env_map'
|
|
7
|
+
require_relative 'frontend_registry'
|
|
7
8
|
require_relative 'terraform_command'
|
|
8
9
|
|
|
9
10
|
module Belt
|
|
@@ -16,6 +17,7 @@ module Belt
|
|
|
16
17
|
def self.run(args)
|
|
17
18
|
port = DEFAULT_PORT
|
|
18
19
|
open_browser = false
|
|
20
|
+
frontend_name = FrontendRegistry.extract_flag!(args, '--frontend')
|
|
19
21
|
|
|
20
22
|
i = 0
|
|
21
23
|
while i < args.length
|
|
@@ -34,7 +36,7 @@ module Belt
|
|
|
34
36
|
i += 1
|
|
35
37
|
end
|
|
36
38
|
|
|
37
|
-
new(port: port, open_browser: open_browser).run
|
|
39
|
+
new(port: port, open_browser: open_browser, frontend_name: frontend_name).run
|
|
38
40
|
end
|
|
39
41
|
|
|
40
42
|
def self.help_text
|
|
@@ -45,37 +47,41 @@ module Belt
|
|
|
45
47
|
belt s [options]
|
|
46
48
|
|
|
47
49
|
Options:
|
|
48
|
-
-p, --port PORT
|
|
49
|
-
|
|
50
|
-
-
|
|
50
|
+
-p, --port PORT Port to serve on (default: #{DEFAULT_PORT})
|
|
51
|
+
--frontend NAME Which frontend to start (when several exist)
|
|
52
|
+
-o, --open Open browser after starting
|
|
53
|
+
-h, --help Show this help
|
|
51
54
|
|
|
52
55
|
Behavior:
|
|
53
|
-
• If frontend
|
|
54
|
-
Injects env from frontend
|
|
56
|
+
• If a frontend exists → runs its dev server (npx vite)
|
|
57
|
+
Injects env from <frontend>/env.yml (or default VITE_API_URL) using
|
|
55
58
|
terraform outputs when available.
|
|
56
59
|
• If no frontend → serves the welcome page via a local HTTP server
|
|
57
60
|
After deploy, shows live API URL and deployment status.
|
|
58
61
|
|
|
59
62
|
Note: The backend is serverless (AWS Lambda). Use `belt deploy` to deploy
|
|
60
63
|
your backend to AWS. Local frontend development reads the env map (or
|
|
61
|
-
frontend
|
|
64
|
+
<frontend>/.env via `belt frontend env <env>`).
|
|
62
65
|
|
|
63
66
|
Examples:
|
|
64
|
-
belt server
|
|
65
|
-
belt s -p 4000
|
|
66
|
-
belt s --
|
|
67
|
+
belt server # Start on port #{DEFAULT_PORT}
|
|
68
|
+
belt s -p 4000 # Start on port 4000
|
|
69
|
+
belt s --frontend ops # Start the ops frontend
|
|
70
|
+
belt s --open # Start and open browser
|
|
67
71
|
HELP
|
|
68
72
|
end
|
|
69
73
|
|
|
70
|
-
def initialize(port:, open_browser: false)
|
|
74
|
+
def initialize(port:, open_browser: false, frontend_name: nil)
|
|
71
75
|
@port = port
|
|
72
76
|
@open_browser = open_browser
|
|
77
|
+
@frontend_name = frontend_name
|
|
73
78
|
@app_name = detect_app_name
|
|
74
79
|
@api_url = detect_api_url
|
|
80
|
+
@frontend = resolve_frontend
|
|
75
81
|
end
|
|
76
82
|
|
|
77
83
|
def run
|
|
78
|
-
if
|
|
84
|
+
if @frontend&.exists?
|
|
79
85
|
run_frontend_dev_server
|
|
80
86
|
else
|
|
81
87
|
run_welcome_server
|
|
@@ -84,8 +90,15 @@ module Belt
|
|
|
84
90
|
|
|
85
91
|
private
|
|
86
92
|
|
|
93
|
+
def resolve_frontend
|
|
94
|
+
registry = FrontendRegistry.new
|
|
95
|
+
return nil if registry.empty? && @frontend_name.nil?
|
|
96
|
+
|
|
97
|
+
registry.resolve!(@frontend_name)
|
|
98
|
+
end
|
|
99
|
+
|
|
87
100
|
def run_frontend_dev_server
|
|
88
|
-
puts "🚀 Starting frontend dev server on port #{@port}..."
|
|
101
|
+
puts "🚀 Starting #{@frontend.label} dev server on port #{@port}..."
|
|
89
102
|
build_env = frontend_process_env
|
|
90
103
|
api_url = build_env['VITE_API_URL'] || build_env['REACT_APP_API_URL'] ||
|
|
91
104
|
build_env['NEXT_PUBLIC_API_URL'] || @api_url
|
|
@@ -102,7 +115,7 @@ module Belt
|
|
|
102
115
|
env = { 'PORT' => @port.to_s }.merge(build_env)
|
|
103
116
|
|
|
104
117
|
# Prefer the dev script with the port flag for Vite-based setups
|
|
105
|
-
Dir.chdir(
|
|
118
|
+
Dir.chdir(@frontend.path) do
|
|
106
119
|
exec(env, 'npx', 'vite', '--port', @port.to_s)
|
|
107
120
|
end
|
|
108
121
|
end
|
|
@@ -112,7 +125,7 @@ module Belt
|
|
|
112
125
|
env_name = @deploy_env || ENV.fetch('BELT_ENV', nil) || TerraformCommand.list_environments.first
|
|
113
126
|
return {} unless env_name
|
|
114
127
|
|
|
115
|
-
FrontendEnvMap.new(env_name).process_env
|
|
128
|
+
FrontendEnvMap.new(env_name, frontend_path: @frontend.path).process_env
|
|
116
129
|
rescue StandardError
|
|
117
130
|
# Fall back to legacy api_url detection if map resolution fails
|
|
118
131
|
@api_url ? { 'VITE_API_URL' => @api_url } : {}
|
|
@@ -243,7 +243,9 @@ module Belt
|
|
|
243
243
|
end
|
|
244
244
|
|
|
245
245
|
def table_name(model_name)
|
|
246
|
-
|
|
246
|
+
# Dasherize to match ActiveItem's table_name_for convention:
|
|
247
|
+
# class_name.underscore.dasherize.pluralize
|
|
248
|
+
"${var.app_name}-${var.environment}-#{Belt::Inflector.pluralize(model_name).tr('_', '-')}"
|
|
247
249
|
end
|
|
248
250
|
end
|
|
249
251
|
end
|
|
@@ -55,6 +55,7 @@ module Belt
|
|
|
55
55
|
def run
|
|
56
56
|
validate!
|
|
57
57
|
apply_env_config!
|
|
58
|
+
build_zip_artifacts! if %w[plan apply].include?(@action)
|
|
58
59
|
env_dir = File.join(@infra_dir, @env)
|
|
59
60
|
args = ['terraform', @action, *@extra_args]
|
|
60
61
|
puts "belt → #{args.join(' ')} (in #{env_dir}/)"
|
|
@@ -69,6 +70,14 @@ module Belt
|
|
|
69
70
|
puts " 🔑 Using AWS profile: #{env_config.aws_profile}" if env_config.aws_profile?
|
|
70
71
|
end
|
|
71
72
|
|
|
73
|
+
def build_zip_artifacts!
|
|
74
|
+
require_relative 'zip_artifact_builder'
|
|
75
|
+
ZipArtifactBuilder.build!(
|
|
76
|
+
project_root: File.expand_path('..', @infra_dir),
|
|
77
|
+
infra_dir: @infra_dir
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
72
81
|
def validate!
|
|
73
82
|
unless @infra_dir
|
|
74
83
|
abort "Error: No infrastructure/ directory found. Run `belt generate environment #{@env}` first."
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require 'fileutils'
|
|
4
4
|
require 'erb'
|
|
5
5
|
require_relative '../inflector'
|
|
6
|
+
require_relative 'frontend_registry'
|
|
6
7
|
|
|
7
8
|
module Belt
|
|
8
9
|
module CLI
|
|
@@ -11,16 +12,19 @@ module Belt
|
|
|
11
12
|
|
|
12
13
|
def self.run(args)
|
|
13
14
|
force = args.delete('--force') || args.delete('-f')
|
|
15
|
+
frontend_name = FrontendRegistry.extract_flag!(args, '--frontend')
|
|
14
16
|
|
|
15
17
|
name = args.shift
|
|
16
18
|
if name.nil? || name.empty?
|
|
17
19
|
puts 'Usage: belt generate views <resource> [field:type ...] [options]'
|
|
18
20
|
puts "\nGenerates React pages for all REST actions (index, show, new, edit)."
|
|
19
21
|
puts "\nOptions:"
|
|
20
|
-
puts ' --force, -f
|
|
22
|
+
puts ' --force, -f Overwrite existing files without prompting'
|
|
23
|
+
puts ' --frontend NAME Target frontend when several exist'
|
|
21
24
|
puts "\nExamples:"
|
|
22
25
|
puts ' belt generate views post title:string content:text status:string'
|
|
23
26
|
puts ' belt generate views comment body:text author:string'
|
|
27
|
+
puts ' belt generate views bag --frontend ops'
|
|
24
28
|
exit 1
|
|
25
29
|
end
|
|
26
30
|
|
|
@@ -32,7 +36,8 @@ module Belt
|
|
|
32
36
|
# If no fields provided, try to read from contracts.rb
|
|
33
37
|
fields = read_schema_fields(name) if fields.empty?
|
|
34
38
|
|
|
35
|
-
new(
|
|
39
|
+
frontend = FrontendRegistry.new.resolve!(frontend_name)
|
|
40
|
+
new(name, fields, force: force, frontend: frontend).generate
|
|
36
41
|
end
|
|
37
42
|
|
|
38
43
|
def self.read_schema_fields(name)
|
|
@@ -75,23 +80,25 @@ module Belt
|
|
|
75
80
|
end
|
|
76
81
|
end
|
|
77
82
|
|
|
78
|
-
def initialize(name, fields, force: false)
|
|
83
|
+
def initialize(name, fields, force: false, quiet: false, frontend: nil)
|
|
79
84
|
@name = name.downcase.gsub(/[^a-z0-9_]/, '_')
|
|
80
85
|
@fields = fields
|
|
81
86
|
@force = force
|
|
87
|
+
@quiet = quiet
|
|
82
88
|
@overwrite_all = false
|
|
83
89
|
@singular_name = Belt::Inflector.singularize(@name)
|
|
84
90
|
@resource_name = Belt::Inflector.pluralize(@singular_name)
|
|
85
91
|
@class_name = Belt::Inflector.classify(@singular_name)
|
|
92
|
+
@frontend = frontend || FrontendRegistry.new.resolve!
|
|
86
93
|
end
|
|
87
94
|
|
|
88
95
|
def generate
|
|
89
|
-
unless Dir.exist?(
|
|
90
|
-
puts
|
|
96
|
+
unless Dir.exist?(@frontend.src_dir)
|
|
97
|
+
puts "✗ No #{@frontend.src_dir}/ directory found. Run `belt generate frontend react` first."
|
|
91
98
|
exit 1
|
|
92
99
|
end
|
|
93
100
|
|
|
94
|
-
pages_dir = "frontend/
|
|
101
|
+
pages_dir = "#{@frontend.src_dir}/pages/#{@resource_name}"
|
|
95
102
|
@plural_class_name = Belt::Inflector.camelize(@resource_name)
|
|
96
103
|
FileUtils.mkdir_p(pages_dir)
|
|
97
104
|
|
|
@@ -103,14 +110,7 @@ module Belt
|
|
|
103
110
|
|
|
104
111
|
inject_routes
|
|
105
112
|
|
|
106
|
-
puts "\n✓ Views for '#{@singular_name}' generated!"
|
|
107
|
-
puts "\nFiles created:"
|
|
108
|
-
puts " #{pages_dir}/#{@plural_class_name}Index.jsx"
|
|
109
|
-
puts " #{pages_dir}/#{@class_name}Show.jsx"
|
|
110
|
-
puts " #{pages_dir}/#{@class_name}New.jsx"
|
|
111
|
-
puts " #{pages_dir}/#{@class_name}Edit.jsx"
|
|
112
|
-
puts " #{pages_dir}/#{@class_name}Form.jsx"
|
|
113
|
-
puts ' frontend/src/App.jsx (updated)'
|
|
113
|
+
puts "\n✓ Views for '#{@singular_name}' generated!" unless @quiet
|
|
114
114
|
end
|
|
115
115
|
|
|
116
116
|
private
|
|
@@ -176,7 +176,7 @@ module Belt
|
|
|
176
176
|
end
|
|
177
177
|
|
|
178
178
|
def inject_routes
|
|
179
|
-
app_jsx =
|
|
179
|
+
app_jsx = @frontend.app_jsx
|
|
180
180
|
return unless File.exist?(app_jsx)
|
|
181
181
|
|
|
182
182
|
content = File.read(app_jsx)
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'open3'
|
|
6
|
+
|
|
7
|
+
module Belt
|
|
8
|
+
module CLI
|
|
9
|
+
# Builds zip files that Terraform `filebase64sha256(...)` / `filename = "...zip"`
|
|
10
|
+
# references before plan/apply. Conveyor Belt packages Ruby lambdas itself;
|
|
11
|
+
# sidecar functions (Node image processors, Cognito triggers, …) are plain
|
|
12
|
+
# `aws_lambda_function` resources that expect a zip on disk at plan time.
|
|
13
|
+
#
|
|
14
|
+
# Node packages (`package.json`) are installed in Docker on linux/amd64 so
|
|
15
|
+
# native addons like `sharp` match Lambda. Plain JS directories are zipped
|
|
16
|
+
# as-is. Existing zips are reused when a source hash still matches.
|
|
17
|
+
class ZipArtifactBuilder
|
|
18
|
+
NODE_DOCKER_IMAGE = 'public.ecr.aws/lambda/nodejs:20-x86_64'
|
|
19
|
+
ZIP_REF = /(?:filebase64sha256\(\s*|filename\s*=\s*)["']([^"']+\.zip)["']/
|
|
20
|
+
|
|
21
|
+
def self.build!(project_root: Dir.pwd, infra_dir: 'infrastructure')
|
|
22
|
+
new(project_root: project_root, infra_dir: infra_dir).build!
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize(project_root:, infra_dir:)
|
|
26
|
+
@project_root = File.expand_path(project_root)
|
|
27
|
+
@infra_dir = File.expand_path(infra_dir, @project_root)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
Artifact = Struct.new(:zip_path, :source_dir, keyword_init: true)
|
|
31
|
+
|
|
32
|
+
def build!
|
|
33
|
+
artifacts = discover_artifacts
|
|
34
|
+
return if artifacts.empty?
|
|
35
|
+
|
|
36
|
+
artifacts.each { |artifact| ensure_zip!(artifact) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def discover_artifacts
|
|
42
|
+
return [] unless Dir.exist?(@infra_dir)
|
|
43
|
+
|
|
44
|
+
zips = []
|
|
45
|
+
tf_files.each do |tf_file|
|
|
46
|
+
File.read(tf_file).scan(ZIP_REF).flatten.each do |raw_path|
|
|
47
|
+
zip_path = resolve_zip_path(tf_file, raw_path)
|
|
48
|
+
next unless zip_path
|
|
49
|
+
|
|
50
|
+
zips << zip_path
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
zips.uniq.filter_map do |zip_path|
|
|
55
|
+
source_dir = File.dirname(zip_path)
|
|
56
|
+
next unless Dir.exist?(source_dir)
|
|
57
|
+
|
|
58
|
+
Artifact.new(zip_path: zip_path, source_dir: source_dir)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def tf_files
|
|
63
|
+
Dir.glob(File.join(@infra_dir, '**/*.tf')).reject { |path| path.include?('/.terraform/') }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Skip interpolations other than ${path.module} — we can't resolve those
|
|
67
|
+
# without running terraform.
|
|
68
|
+
def resolve_zip_path(tf_file, raw_path)
|
|
69
|
+
return if raw_path.match?(/\$\{(?!path\.module\})/)
|
|
70
|
+
|
|
71
|
+
tf_dir = File.dirname(File.expand_path(tf_file, @project_root))
|
|
72
|
+
expanded = raw_path.gsub('${path.module}', tf_dir)
|
|
73
|
+
File.expand_path(expanded)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def ensure_zip!(artifact)
|
|
77
|
+
label = relative_to_root(artifact.zip_path)
|
|
78
|
+
zip_exists = File.file?(artifact.zip_path)
|
|
79
|
+
hashed = File.file?(hash_file_for(artifact))
|
|
80
|
+
|
|
81
|
+
if zip_exists && (!hashed || hash_unchanged?(artifact))
|
|
82
|
+
puts " ♻️ #{label} unchanged — skipping rebuild" if hashed
|
|
83
|
+
return
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
puts " 📦 Building #{label}..."
|
|
87
|
+
if node_package?(artifact.source_dir)
|
|
88
|
+
build_node_zip!(artifact)
|
|
89
|
+
else
|
|
90
|
+
build_js_zip!(artifact)
|
|
91
|
+
end
|
|
92
|
+
write_hash!(artifact)
|
|
93
|
+
puts " ✅ #{label} ready"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def node_package?(dir)
|
|
97
|
+
File.file?(File.join(dir, 'package.json'))
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def build_node_zip!(artifact)
|
|
101
|
+
ensure_docker!
|
|
102
|
+
install_node_modules!(artifact.source_dir)
|
|
103
|
+
zip_contents!(artifact, node_zip_entries(artifact.source_dir))
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def build_js_zip!(artifact)
|
|
107
|
+
entries = js_zip_entries(artifact.source_dir)
|
|
108
|
+
abort "✗ #{relative_to_root(artifact.source_dir)} has no .js/.mjs files to zip" if entries.empty?
|
|
109
|
+
|
|
110
|
+
zip_contents!(artifact, entries)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def node_zip_entries(dir)
|
|
114
|
+
entries = js_zip_entries(dir)
|
|
115
|
+
entries << 'package.json' if File.file?(File.join(dir, 'package.json'))
|
|
116
|
+
entries << 'node_modules' if Dir.exist?(File.join(dir, 'node_modules'))
|
|
117
|
+
entries.uniq
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def js_zip_entries(dir)
|
|
121
|
+
Dir.children(dir).grep(/\.(mjs|js|cjs)\z/).sort
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def install_node_modules!(source_dir)
|
|
125
|
+
lockfile = File.join(source_dir, 'package-lock.json')
|
|
126
|
+
install = File.file?(lockfile) ? 'npm ci --omit=dev' : 'npm install --omit=dev'
|
|
127
|
+
uid = Process.uid
|
|
128
|
+
gid = Process.gid
|
|
129
|
+
|
|
130
|
+
docker_cmd = [
|
|
131
|
+
'docker', 'run', '--rm',
|
|
132
|
+
'--platform', 'linux/amd64',
|
|
133
|
+
'--entrypoint', '',
|
|
134
|
+
'-v', "#{source_dir}:/var/task",
|
|
135
|
+
'-w', '/var/task',
|
|
136
|
+
NODE_DOCKER_IMAGE,
|
|
137
|
+
'/bin/bash', '-c',
|
|
138
|
+
"rm -rf node_modules && #{install} && chown -R #{uid}:#{gid} ."
|
|
139
|
+
]
|
|
140
|
+
|
|
141
|
+
output, status = Open3.capture2e(*docker_cmd)
|
|
142
|
+
return if status.success?
|
|
143
|
+
|
|
144
|
+
puts output
|
|
145
|
+
abort "\n✗ Node Lambda build failed for #{relative_to_root(source_dir)}. " \
|
|
146
|
+
'Is Docker running?'
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def zip_contents!(artifact, entries)
|
|
150
|
+
FileUtils.rm_f(artifact.zip_path)
|
|
151
|
+
zip_name = File.basename(artifact.zip_path)
|
|
152
|
+
|
|
153
|
+
Dir.chdir(artifact.source_dir) do
|
|
154
|
+
output, status = Open3.capture2e('zip', '-qr', zip_name, *entries)
|
|
155
|
+
unless status.success?
|
|
156
|
+
puts output
|
|
157
|
+
abort "\n✗ zip failed for #{relative_to_root(artifact.zip_path)}"
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def ensure_docker!
|
|
163
|
+
_, status = Open3.capture2e('docker', 'info')
|
|
164
|
+
return if status.success?
|
|
165
|
+
|
|
166
|
+
abort "✗ Docker is not running. It's required to build Node Lambda zips for linux/amd64."
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def hash_unchanged?(artifact)
|
|
170
|
+
hash_file = hash_file_for(artifact)
|
|
171
|
+
return false unless File.file?(hash_file)
|
|
172
|
+
|
|
173
|
+
File.read(hash_file).strip == source_hash(artifact.source_dir)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def write_hash!(artifact)
|
|
177
|
+
File.write(hash_file_for(artifact), "#{source_hash(artifact.source_dir)}\n")
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def hash_file_for(artifact)
|
|
181
|
+
File.join(artifact.source_dir, ".#{File.basename(artifact.source_dir)}-hash")
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def source_hash(dir)
|
|
185
|
+
files = Dir.children(dir).select do |name|
|
|
186
|
+
name.match?(/\.(mjs|js|cjs)\z/) || name == 'package.json' || name == 'package-lock.json'
|
|
187
|
+
end.sort
|
|
188
|
+
|
|
189
|
+
digest = Digest::SHA256.new
|
|
190
|
+
files.each { |name| digest.update(File.binread(File.join(dir, name))) }
|
|
191
|
+
digest.hexdigest
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def relative_to_root(path)
|
|
195
|
+
path.sub(%r{\A#{Regexp.escape(@project_root)}/?}, '')
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
data/lib/belt/cli.rb
CHANGED
|
@@ -6,6 +6,7 @@ require_relative 'cli/env_resolver'
|
|
|
6
6
|
require_relative 'cli/new_command'
|
|
7
7
|
require_relative 'cli/generate_command'
|
|
8
8
|
require_relative 'cli/destroy_command'
|
|
9
|
+
require_relative 'cli/index_command'
|
|
9
10
|
require_relative 'cli/frontend_command'
|
|
10
11
|
require_relative 'cli/frontend_setup_command'
|
|
11
12
|
require_relative 'cli/frontend_deploy_command'
|
|
@@ -25,6 +26,7 @@ require_relative 'cli/console_command'
|
|
|
25
26
|
require_relative 'cli/logs_command'
|
|
26
27
|
require_relative 'cli/doctor_command'
|
|
27
28
|
require_relative 'cli/plugin_command'
|
|
29
|
+
require_relative 'cli/explain_command'
|
|
28
30
|
|
|
29
31
|
module Belt
|
|
30
32
|
module CLI
|
|
@@ -41,6 +43,7 @@ module Belt
|
|
|
41
43
|
'setup' => Belt::CLI::SetupCommand,
|
|
42
44
|
'doctor' => Belt::CLI::DoctorCommand,
|
|
43
45
|
'plugin' => Belt::CLI::PluginCommand,
|
|
46
|
+
'explain' => Belt::CLI::ExplainCommand,
|
|
44
47
|
'deploy' => Belt::CLI::DeployCommand,
|
|
45
48
|
'frontend' => Belt::CLI::FrontendEnvCommand,
|
|
46
49
|
%w[server s] => Belt::CLI::ServerCommand,
|
|
@@ -54,7 +57,7 @@ module Belt
|
|
|
54
57
|
TERRAFORM_ACTIONS = Belt::CLI::TerraformCommand::ACTIONS
|
|
55
58
|
|
|
56
59
|
# Commands that can run without being inside a Belt project
|
|
57
|
-
STANDALONE_COMMANDS = %w[new version --version -v doctor].freeze
|
|
60
|
+
STANDALONE_COMMANDS = %w[new version --version -v doctor explain].freeze
|
|
58
61
|
|
|
59
62
|
def self.start(args)
|
|
60
63
|
command = args.shift
|
|
@@ -97,18 +100,19 @@ module Belt
|
|
|
97
100
|
Commands:
|
|
98
101
|
new <app_name> [--frontend react] Create a new Belt application
|
|
99
102
|
generate <scaffold|model|controller> <name> Generate components
|
|
100
|
-
generate frontend <react|vue|svelte> Scaffold a frontend app
|
|
101
|
-
generate views <resource> [fields...] Generate React pages
|
|
103
|
+
generate frontend <react|vue|svelte> Scaffold a frontend app [--name --path]
|
|
104
|
+
generate views <resource> [fields...] Generate React pages [--frontend NAME]
|
|
102
105
|
generate environment <name> Create a new environment
|
|
103
106
|
destroy <scaffold|model|controller> <name> Remove generated components
|
|
104
|
-
destroy frontend
|
|
107
|
+
destroy frontend [--frontend NAME] Remove a frontend directory
|
|
105
108
|
destroy views <resource> Remove React pages for a resource
|
|
106
109
|
destroy environment <name> Remove an environment directory
|
|
107
|
-
server
|
|
110
|
+
server [--frontend NAME] Start local dev server (frontend)
|
|
108
111
|
s Alias for server
|
|
109
112
|
deploy [environment] Deploy to AWS (init → plan → apply)
|
|
110
|
-
deploy frontend <env>
|
|
111
|
-
frontend env <env>
|
|
113
|
+
deploy frontend <env> [--frontend NAME] Build and deploy frontend(s) to AWS
|
|
114
|
+
frontend env <env> [--frontend NAME] Write <frontend>/.env from terraform outputs
|
|
115
|
+
frontend list List configured frontends
|
|
112
116
|
routes [-g PATTERN] [-f json] Show route definitions
|
|
113
117
|
contracts [-g PATTERN] [-f json] Show API request/response contracts
|
|
114
118
|
lambda-config [-e ENV] [-f json|terraform] Show merged lambda configuration
|
|
@@ -120,9 +124,10 @@ module Belt
|
|
|
120
124
|
-T [-g PATTERN] [-a] Alias for tasks
|
|
121
125
|
setup state Create/select S3 state bucket
|
|
122
126
|
setup tables <env> Generate DynamoDB tables from schema
|
|
123
|
-
setup frontend
|
|
127
|
+
setup frontend [--name NAME] Generate S3 + CloudFront infrastructure
|
|
124
128
|
doctor Check system dependencies and AWS config
|
|
125
129
|
plugin new <name> Scaffold a new Belt plugin gem
|
|
130
|
+
explain <topic> Explain a Belt concept (routing, models, …)
|
|
126
131
|
init [environment] <env> terraform init for environment
|
|
127
132
|
plan [environment] <env> terraform plan for environment
|
|
128
133
|
apply [environment] <env> terraform apply for environment
|
|
@@ -146,11 +151,14 @@ module Belt
|
|
|
146
151
|
belt generate scaffold post title:string content:text status:string
|
|
147
152
|
belt destroy scaffold post
|
|
148
153
|
belt generate frontend react
|
|
154
|
+
belt generate frontend react --name ops --path ops-app
|
|
149
155
|
belt server # Start local frontend server
|
|
156
|
+
belt server --frontend ops
|
|
150
157
|
belt deploy # Deploy dev to AWS
|
|
151
158
|
belt deploy prod --auto # Deploy prod without confirmation
|
|
152
159
|
belt deploy frontend wups
|
|
153
|
-
belt frontend
|
|
160
|
+
belt deploy frontend wups --frontend ops
|
|
161
|
+
belt frontend env wups # Smart-merge TF outputs into <frontend>/.env
|
|
154
162
|
belt setup frontend wups
|
|
155
163
|
belt apply wups
|
|
156
164
|
belt tasks # list all rake tasks
|