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.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +241 -0
  3. data/README.md +42 -3
  4. data/lib/belt/action_router.rb +34 -19
  5. data/lib/belt/cli/app_detection.rb +30 -3
  6. data/lib/belt/cli/auth_command.rb +395 -0
  7. data/lib/belt/cli/console_command.rb +17 -2
  8. data/lib/belt/cli/deploy_command.rb +51 -17
  9. data/lib/belt/cli/destroy_command.rb +62 -25
  10. data/lib/belt/cli/doctor_command.rb +166 -22
  11. data/lib/belt/cli/environment_command.rb +12 -0
  12. data/lib/belt/cli/explain_command.rb +112 -0
  13. data/lib/belt/cli/frontend_command.rb +31 -10
  14. data/lib/belt/cli/frontend_deploy_command.rb +110 -21
  15. data/lib/belt/cli/frontend_env_command.rb +56 -19
  16. data/lib/belt/cli/frontend_env_map.rb +30 -10
  17. data/lib/belt/cli/frontend_registry.rb +373 -0
  18. data/lib/belt/cli/frontend_setup_command.rb +71 -5
  19. data/lib/belt/cli/generate_command.rb +61 -42
  20. data/lib/belt/cli/index_command.rb +192 -0
  21. data/lib/belt/cli/lambda_config_command.rb +9 -3
  22. data/lib/belt/cli/routes_command/request_model_inference.rb +131 -0
  23. data/lib/belt/cli/routes_command/route_inference.rb +4 -3
  24. data/lib/belt/cli/routes_command.rb +31 -19
  25. data/lib/belt/cli/server_command.rb +28 -15
  26. data/lib/belt/cli/tables_command.rb +3 -1
  27. data/lib/belt/cli/terraform_command.rb +9 -0
  28. data/lib/belt/cli/views_command.rb +15 -15
  29. data/lib/belt/cli/zip_artifact_builder.rb +199 -0
  30. data/lib/belt/cli.rb +17 -9
  31. data/lib/belt/docs/backups.md +101 -0
  32. data/lib/belt/docs/console.md +65 -0
  33. data/lib/belt/docs/controllers.md +155 -0
  34. data/lib/belt/docs/deployment.md +166 -0
  35. data/lib/belt/docs/frontend.md +103 -0
  36. data/lib/belt/docs/generators.md +137 -0
  37. data/lib/belt/docs/lambda_handler.md +105 -0
  38. data/lib/belt/docs/models.md +160 -0
  39. data/lib/belt/docs/observability.md +94 -0
  40. data/lib/belt/docs/plugins.md +94 -0
  41. data/lib/belt/docs/routing.md +138 -0
  42. data/lib/belt/docs/structure.md +96 -0
  43. data/lib/belt/inflector.rb +8 -0
  44. data/lib/belt/route_dsl.rb +318 -100
  45. data/lib/belt/version.rb +1 -1
  46. data/lib/templates/frontend/react/env.yml.example +2 -2
  47. data/lib/templates/generate/auth/cognito.tf.erb +71 -0
  48. data/lib/templates/generate/auth/cognito_outputs.tf.erb +19 -0
  49. data/lib/templates/generate/auth/frontend/ConfirmEmail.jsx +55 -0
  50. data/lib/templates/generate/auth/frontend/Login.jsx +79 -0
  51. data/lib/templates/generate/auth/frontend/ProtectedRoute.jsx +9 -0
  52. data/lib/templates/generate/auth/frontend/SignUp.jsx +58 -0
  53. data/lib/templates/generate/auth/frontend/apiClient.js +34 -0
  54. data/lib/templates/generate/auth/frontend/auth.css +157 -0
  55. data/lib/templates/generate/auth/frontend/auth.js +134 -0
  56. data/lib/templates/generate/model.rb.erb +4 -6
  57. data/lib/templates/module/frontend.tf.erb +49 -43
  58. data/lib/templates/module/outputs.tf.erb +1 -1
  59. data/lib/templates/new_app/AGENTS.md.erb +170 -20
  60. data/lib/templates/new_app/config/lambda/api.yml.erb +1 -0
  61. data/lib/templates/new_app/config/routes.rb.erb +8 -3
  62. data/lib/templates/new_app/lambda/api.rb.erb +1 -1
  63. 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 Port to serve on (default: #{DEFAULT_PORT})
49
- -o, --open Open browser after starting
50
- -h, --help Show this help
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/ exists → runs the frontend dev server (npm run dev)
54
- Injects env from frontend/env.yml (or default VITE_API_URL) using
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/.env via `belt frontend env <env>`).
64
+ <frontend>/.env via `belt frontend env <env>`).
62
65
 
63
66
  Examples:
64
- belt server # Start on port #{DEFAULT_PORT}
65
- belt s -p 4000 # Start on port 4000
66
- belt s --open # Start and open browser
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 Dir.exist?('frontend') && File.exist?('frontend/package.json')
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('frontend') do
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
- "${var.app_name}-${var.environment}-#{Belt::Inflector.pluralize(model_name)}"
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 Overwrite existing files without prompting'
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(name, fields, force: force).generate
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?('frontend/src')
90
- puts '✗ No frontend/ directory found. Run `belt generate frontend react` first.'
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/src/pages/#{@resource_name}"
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 = 'frontend/src/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 for REST actions
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 Remove the frontend/ directory
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 Start local dev server (frontend)
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> Build and deploy frontend to AWS
111
- frontend env <env> Write frontend/.env from terraform outputs
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 <env> Generate S3 + CloudFront infrastructure
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 env wups # Smart-merge TF outputs into frontend/.env
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