belt 0.3.4 → 0.3.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.
Potentially problematic release.
This version of belt might be problematic. Click here for more details.
- checksums.yaml +4 -4
- data/CHANGELOG.md +52 -0
- data/lib/belt/cli/auth_command.rb +27 -12
- data/lib/belt/cli/deploy_command.rb +27 -14
- data/lib/belt/cli/destroy_command.rb +55 -24
- data/lib/belt/cli/environment_command.rb +12 -0
- data/lib/belt/cli/explain_command.rb +3 -1
- 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 +55 -18
- data/lib/belt/cli/frontend_env_map.rb +27 -9
- 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 +21 -6
- data/lib/belt/cli/routes_command/request_model_inference.rb +131 -0
- data/lib/belt/cli/routes_command.rb +7 -0
- data/lib/belt/cli/server_command.rb +28 -15
- data/lib/belt/cli/terraform_command.rb +9 -0
- data/lib/belt/cli/views_command.rb +13 -7
- data/lib/belt/cli/zip_artifact_builder.rb +199 -0
- data/lib/belt/cli.rb +12 -8
- data/lib/belt/docs/deployment.md +27 -2
- data/lib/belt/docs/frontend.md +103 -0
- data/lib/belt/docs/generators.md +9 -0
- data/lib/belt/docs/structure.md +3 -0
- data/lib/belt/route_dsl.rb +113 -23
- data/lib/belt/version.rb +1 -1
- data/lib/templates/frontend/react/env.yml.example +1 -1
- data/lib/templates/module/frontend.tf.erb +49 -43
- data/lib/templates/new_app/AGENTS.md.erb +29 -0
- metadata +5 -1
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Belt
|
|
4
|
+
module CLI
|
|
5
|
+
class RoutesCommand
|
|
6
|
+
# Infers request_model and response_model from contracts using naming conventions.
|
|
7
|
+
#
|
|
8
|
+
# Request model convention cascade (first match wins):
|
|
9
|
+
# 1. :<verb>_<gateway>_<singular_resource> (e.g. :create_customer_item)
|
|
10
|
+
# 2. :<verb>_<singular_resource> (e.g. :create_item)
|
|
11
|
+
#
|
|
12
|
+
# Response model convention:
|
|
13
|
+
# Singular of the resource name matches a `model` in contracts.
|
|
14
|
+
# e.g. `resources :items` → looks for `model :item` in contracts.rb
|
|
15
|
+
#
|
|
16
|
+
# Only request_model inference applies to body-accepting verbs (POST/PUT/PATCH).
|
|
17
|
+
# Response model inference applies to all verbs on resource routes.
|
|
18
|
+
# Explicit values always win — inference only fills in blanks.
|
|
19
|
+
module RequestModelInference
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
# Applies convention-based request_model inference to all routes.
|
|
23
|
+
# Requires the set of known contract names from contracts.rb.
|
|
24
|
+
def infer_request_models!(routes, contracts_file)
|
|
25
|
+
contract_names = load_contract_names(contracts_file)
|
|
26
|
+
return if contract_names[:request].empty? && contract_names[:response].empty?
|
|
27
|
+
|
|
28
|
+
routes.each do |route|
|
|
29
|
+
infer_request_model_for!(route, contract_names[:request])
|
|
30
|
+
infer_response_model_for!(route, contract_names[:response])
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def load_contract_names(contracts_file)
|
|
35
|
+
empty_result = { request: Set.new, response: Set.new }
|
|
36
|
+
return empty_result unless contracts_file && File.exist?(contracts_file)
|
|
37
|
+
|
|
38
|
+
# Reset application state for clean contract loading
|
|
39
|
+
Belt.instance_variable_set(:@application, nil)
|
|
40
|
+
begin
|
|
41
|
+
eval(File.read(contracts_file), binding, contracts_file) # rubocop:disable Security/Eval
|
|
42
|
+
rescue StandardError => e
|
|
43
|
+
warn "Warning: Failed to load contracts for inference: #{e.message}"
|
|
44
|
+
return empty_result
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
schema = Belt.application.schema.to_h
|
|
48
|
+
request_names = Set.new
|
|
49
|
+
response_names = Set.new
|
|
50
|
+
(schema[:request_models] || {}).each_key { |name| request_names << name.to_s }
|
|
51
|
+
(schema[:response_models] || {}).each_key { |name| response_names << name.to_s }
|
|
52
|
+
{ request: request_names, response: response_names }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def infer_request_model_for!(route, request_contracts)
|
|
56
|
+
return if request_contracts.empty?
|
|
57
|
+
return unless route[:request_model].to_s.empty?
|
|
58
|
+
return unless body_accepting_verb?(route[:verb])
|
|
59
|
+
|
|
60
|
+
inferred = infer_request_model_for_route(route, request_contracts)
|
|
61
|
+
route[:request_model] = inferred if inferred
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def infer_response_model_for!(route, response_contracts)
|
|
65
|
+
return if response_contracts.empty?
|
|
66
|
+
return unless route[:response_model].to_s.empty?
|
|
67
|
+
|
|
68
|
+
inferred = infer_response_model_for_route(route, response_contracts)
|
|
69
|
+
route[:response_model] = inferred if inferred
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def infer_request_model_for_route(route, contract_names)
|
|
73
|
+
verb_prefix = infer_verb_prefix(route[:verb], route[:action])
|
|
74
|
+
return nil unless verb_prefix
|
|
75
|
+
|
|
76
|
+
resource_name = extract_singular_resource(route)
|
|
77
|
+
return nil unless resource_name
|
|
78
|
+
|
|
79
|
+
gateway = route[:gateway]
|
|
80
|
+
|
|
81
|
+
# Cascade: gateway-scoped first, then generic
|
|
82
|
+
candidates = [
|
|
83
|
+
"#{verb_prefix}_#{gateway}_#{resource_name}",
|
|
84
|
+
"#{verb_prefix}_#{resource_name}"
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
candidates.find { |candidate| contract_names.include?(candidate) }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def infer_response_model_for_route(route, response_contracts)
|
|
91
|
+
resource_name = extract_singular_resource(route)
|
|
92
|
+
return nil unless resource_name
|
|
93
|
+
|
|
94
|
+
# Convention: singular resource name matches a response model
|
|
95
|
+
return resource_name if response_contracts.include?(resource_name)
|
|
96
|
+
|
|
97
|
+
nil
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def infer_verb_prefix(verb, action)
|
|
101
|
+
# Map HTTP verb + action to the contract naming prefix
|
|
102
|
+
case action
|
|
103
|
+
when 'create' then 'create'
|
|
104
|
+
when 'update' then 'update'
|
|
105
|
+
else
|
|
106
|
+
# For non-standard actions on body verbs, don't infer
|
|
107
|
+
case verb
|
|
108
|
+
when 'POST' then 'create'
|
|
109
|
+
when 'PUT', 'PATCH' then 'update'
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def extract_singular_resource(route)
|
|
115
|
+
# Extract the resource name from the path
|
|
116
|
+
segments = route[:path].split('/').reject(&:empty?)
|
|
117
|
+
# Find the last non-param segment that looks like a resource
|
|
118
|
+
resource_segments = segments.reject { |s| s.start_with?('{', ':') }
|
|
119
|
+
return nil if resource_segments.empty?
|
|
120
|
+
|
|
121
|
+
resource = resource_segments.last
|
|
122
|
+
Belt::Inflector.singularize(resource.gsub('-', '_'))
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def body_accepting_verb?(verb)
|
|
126
|
+
%w[POST PUT PATCH].include?(verb)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -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]
|
|
@@ -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 } : {}
|
|
@@ -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,7 +80,7 @@ module Belt
|
|
|
75
80
|
end
|
|
76
81
|
end
|
|
77
82
|
|
|
78
|
-
def initialize(name, fields, force: false, quiet: 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
|
|
@@ -84,15 +89,16 @@ module Belt
|
|
|
84
89
|
@singular_name = Belt::Inflector.singularize(@name)
|
|
85
90
|
@resource_name = Belt::Inflector.pluralize(@singular_name)
|
|
86
91
|
@class_name = Belt::Inflector.classify(@singular_name)
|
|
92
|
+
@frontend = frontend || FrontendRegistry.new.resolve!
|
|
87
93
|
end
|
|
88
94
|
|
|
89
95
|
def generate
|
|
90
|
-
unless Dir.exist?(
|
|
91
|
-
puts
|
|
96
|
+
unless Dir.exist?(@frontend.src_dir)
|
|
97
|
+
puts "✗ No #{@frontend.src_dir}/ directory found. Run `belt generate frontend react` first."
|
|
92
98
|
exit 1
|
|
93
99
|
end
|
|
94
100
|
|
|
95
|
-
pages_dir = "frontend/
|
|
101
|
+
pages_dir = "#{@frontend.src_dir}/pages/#{@resource_name}"
|
|
96
102
|
@plural_class_name = Belt::Inflector.camelize(@resource_name)
|
|
97
103
|
FileUtils.mkdir_p(pages_dir)
|
|
98
104
|
|
|
@@ -170,7 +176,7 @@ module Belt
|
|
|
170
176
|
end
|
|
171
177
|
|
|
172
178
|
def inject_routes
|
|
173
|
-
app_jsx =
|
|
179
|
+
app_jsx = @frontend.app_jsx
|
|
174
180
|
return unless File.exist?(app_jsx)
|
|
175
181
|
|
|
176
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
|
@@ -100,18 +100,19 @@ module Belt
|
|
|
100
100
|
Commands:
|
|
101
101
|
new <app_name> [--frontend react] Create a new Belt application
|
|
102
102
|
generate <scaffold|model|controller> <name> Generate components
|
|
103
|
-
generate frontend <react|vue|svelte> Scaffold a frontend app
|
|
104
|
-
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]
|
|
105
105
|
generate environment <name> Create a new environment
|
|
106
106
|
destroy <scaffold|model|controller> <name> Remove generated components
|
|
107
|
-
destroy frontend
|
|
107
|
+
destroy frontend [--frontend NAME] Remove a frontend directory
|
|
108
108
|
destroy views <resource> Remove React pages for a resource
|
|
109
109
|
destroy environment <name> Remove an environment directory
|
|
110
|
-
server
|
|
110
|
+
server [--frontend NAME] Start local dev server (frontend)
|
|
111
111
|
s Alias for server
|
|
112
112
|
deploy [environment] Deploy to AWS (init → plan → apply)
|
|
113
|
-
deploy frontend <env>
|
|
114
|
-
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
|
|
115
116
|
routes [-g PATTERN] [-f json] Show route definitions
|
|
116
117
|
contracts [-g PATTERN] [-f json] Show API request/response contracts
|
|
117
118
|
lambda-config [-e ENV] [-f json|terraform] Show merged lambda configuration
|
|
@@ -123,7 +124,7 @@ module Belt
|
|
|
123
124
|
-T [-g PATTERN] [-a] Alias for tasks
|
|
124
125
|
setup state Create/select S3 state bucket
|
|
125
126
|
setup tables <env> Generate DynamoDB tables from schema
|
|
126
|
-
setup frontend
|
|
127
|
+
setup frontend [--name NAME] Generate S3 + CloudFront infrastructure
|
|
127
128
|
doctor Check system dependencies and AWS config
|
|
128
129
|
plugin new <name> Scaffold a new Belt plugin gem
|
|
129
130
|
explain <topic> Explain a Belt concept (routing, models, …)
|
|
@@ -150,11 +151,14 @@ module Belt
|
|
|
150
151
|
belt generate scaffold post title:string content:text status:string
|
|
151
152
|
belt destroy scaffold post
|
|
152
153
|
belt generate frontend react
|
|
154
|
+
belt generate frontend react --name ops --path ops-app
|
|
153
155
|
belt server # Start local frontend server
|
|
156
|
+
belt server --frontend ops
|
|
154
157
|
belt deploy # Deploy dev to AWS
|
|
155
158
|
belt deploy prod --auto # Deploy prod without confirmation
|
|
156
159
|
belt deploy frontend wups
|
|
157
|
-
belt frontend
|
|
160
|
+
belt deploy frontend wups --frontend ops
|
|
161
|
+
belt frontend env wups # Smart-merge TF outputs into <frontend>/.env
|
|
158
162
|
belt setup frontend wups
|
|
159
163
|
belt apply wups
|
|
160
164
|
belt tasks # list all rake tasks
|
data/lib/belt/docs/deployment.md
CHANGED
|
@@ -85,6 +85,25 @@ belt routes --namespace api
|
|
|
85
85
|
|
|
86
86
|
This is typically done automatically by `belt deploy`.
|
|
87
87
|
|
|
88
|
+
## Sidecar Lambda Zips
|
|
89
|
+
|
|
90
|
+
Conveyor Belt packages Ruby lambdas. Some apps also have standalone
|
|
91
|
+
`aws_lambda_function` resources (Node image processors, Cognito triggers)
|
|
92
|
+
whose Terraform uses `filename` + `filebase64sha256` pointing at a zip on disk.
|
|
93
|
+
|
|
94
|
+
`belt deploy`, `belt plan`, and `belt apply` scan `infrastructure/**/*.tf` for
|
|
95
|
+
those zip paths and build any that are missing (or whose source hash changed):
|
|
96
|
+
|
|
97
|
+
- Directory with `package.json` — `npm ci` in Docker (`linux/amd64`, Lambda
|
|
98
|
+
Node image) so native addons like `sharp` match Lambda, then zip
|
|
99
|
+
- Plain JS directory — zip the `.js` / `.mjs` files
|
|
100
|
+
|
|
101
|
+
The zip lives next to the source (`image-processor/image-processor.zip`). This
|
|
102
|
+
is the same pre-terraform step Stowzilla's `scripts/deploy.sh` does for the
|
|
103
|
+
image processor. Existing zips with no hash file are left alone.
|
|
104
|
+
|
|
105
|
+
Docker must be running for Node packages.
|
|
106
|
+
|
|
88
107
|
## Terraform Commands
|
|
89
108
|
|
|
90
109
|
Belt wraps Terraform with environment awareness:
|
|
@@ -130,10 +149,16 @@ belt deploy prod # explicit arg wins
|
|
|
130
149
|
For apps with a frontend:
|
|
131
150
|
|
|
132
151
|
```bash
|
|
133
|
-
belt deploy frontend <env>
|
|
134
|
-
belt frontend
|
|
152
|
+
belt deploy frontend <env> # build + deploy all frontends
|
|
153
|
+
belt deploy frontend <env> --frontend ops # deploy one named frontend
|
|
154
|
+
belt frontend env <env> # generate .env from Terraform outputs
|
|
155
|
+
belt frontend list # show configured frontends
|
|
135
156
|
```
|
|
136
157
|
|
|
158
|
+
The directory is `frontend/` by default. Multiple SPAs are declared in
|
|
159
|
+
`config/frontends.yml` — see `belt explain frontend`. Full `belt deploy <env>`
|
|
160
|
+
deploys every configured frontend after terraform apply.
|
|
161
|
+
|
|
137
162
|
## See Also
|
|
138
163
|
|
|
139
164
|
- `belt explain routing` — how routes map to infrastructure
|