belt 0.2.19 → 0.3.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7f706cafa79246d178c2aee6bfb05536bcb5f03111a24f13624e769ef7d9916a
4
- data.tar.gz: a99d09c6d472d4826acf78dee6a4fded952c8745071b63c6ea4f54dbfd6b0aef
3
+ metadata.gz: cacb19365ad96e079f7a796dc74390f70089bff236581a65b30fabb6e5719008
4
+ data.tar.gz: c723291b6e114a285b146fead8f5626fe812d798c2230f90999bde34af67806a
5
5
  SHA512:
6
- metadata.gz: 2009deb03f2f725c4e960e73abf7745b8a9fdd5899ea76178b3942f21c504fe76338912ba57f05b913db3a18494f635c508e4696a3c347e40ddcb973e4c61541
7
- data.tar.gz: b84417ce5814e266715b5dc54b4a965261f8011fee552f9f594c82137751565ff29b20f17444b2be986abfd79c02059b935ae5aa97fcbdbbfdcc79ffafa0421b
6
+ metadata.gz: 8ac85878df33cec2aa990e527530e54368972e6cca18f18f1e080f4154c50b5d539536fdcb55dfa6b2de675c2f271ed60cb578c3e4acedd4c2bc8c39f48cefd3
7
+ data.tar.gz: fe809339def0c18771870b7bc7bd7740c83aab12dfcef9eb7f4a3b07420bf139dce02fe0c8845bf84e994eb9743e2aa635f0fb0d6e2f0ff369f12b15cabdcc6b
data/CHANGELOG.md CHANGED
@@ -1,5 +1,52 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0
4
+
5
+ Version bump only — no code changes from 0.2.21.
6
+
7
+ **Why 0.3.0 instead of another patch?** The route DSL changes in 0.2.21 (`gateway`/`function`/`namespace`/`scope`) were a minor-version level change (new features, new keywords). RubyGems doesn't allow republishing the same version, so we're bumping to 0.3.0 to correct the semver trajectory.
8
+
9
+ If you're on 0.2.21, you already have the DSL changes. This release is purely for version hygiene.
10
+
11
+ ## 0.2.21
12
+
13
+ ### Route DSL: `gateway`, `function`, `namespace`, and `scope` keywords
14
+
15
+ The route DSL now uses terminology that matches AWS infrastructure:
16
+
17
+ - **`gateway`** — Creates an API Gateway with a default Lambda function of the same name (replaces top-level `namespace`)
18
+ - **`function`** — Targets enclosed routes to a different Lambda function
19
+ - **`namespace`** — Rails-like path + module prefix (e.g., `/admin/users` with `admin/users` controller). Does NOT change Lambda target.
20
+ - **`scope`** — Flexible grouping with `path:`, `module:`, `auth:`, `tables:` options. Does NOT change Lambda target.
21
+
22
+ ```ruby
23
+ Belt.application.routes.draw do
24
+ gateway :api, auth: :cognito do
25
+ resources :posts # → lambda: "api", path: /posts
26
+
27
+ function :onboarding do
28
+ resources :steps # → lambda: "onboarding", path: /steps
29
+ end
30
+
31
+ namespace :admin do
32
+ resources :users # → lambda: "api", path: /admin/users, controller: "admin/users"
33
+ end
34
+
35
+ scope path: 'v2', module: 'v2' do
36
+ resources :widgets # → lambda: "api", path: /v2/widgets, controller: "v2/widgets"
37
+ end
38
+ end
39
+ end
40
+ ```
41
+
42
+ **ActionRouter** now accepts `gateway:` keyword (preferred):
43
+
44
+ ```ruby
45
+ ROUTER = Belt::ActionRouter.new(routes: Routes::API, gateway: 'api')
46
+ ```
47
+
48
+ **100% backward compatible:** `namespace :api do` at top level still works (aliased to `gateway`), and `ActionRouter.new(namespace: 'api')` still works.
49
+
3
50
  ## 0.2.18
4
51
 
5
52
  ### New generator: `belt generate auth`
data/README.md CHANGED
@@ -105,7 +105,7 @@ require "belt"
105
105
 
106
106
  include Belt::LambdaHandler
107
107
 
108
- ROUTER = Belt::ActionRouter.new(routes: Routes::API, namespace: "api")
108
+ ROUTER = Belt::ActionRouter.new(routes: Routes::API, gateway: "api")
109
109
 
110
110
  def execute(path:, body:, event:)
111
111
  ROUTER.route(event: event, body: body)
@@ -140,7 +140,7 @@ Define routes in `infrastructure/routes.tf.rb`:
140
140
 
141
141
  ```ruby
142
142
  Belt.application.routes.draw do
143
- namespace :api do
143
+ gateway :api do
144
144
  resources :posts, only: [:index, :show, :create]
145
145
  end
146
146
  end
@@ -170,6 +170,45 @@ The provider will:
170
170
  - Generate IAM policies for DynamoDB table access
171
171
  - Set up CloudWatch log groups
172
172
 
173
+ ### Route DSL Keywords
174
+
175
+ The routes DSL has four keywords that map to infrastructure and code organization:
176
+
177
+ | Keyword | Purpose | Affects Lambda? |
178
+ |---------|---------|-----------------|
179
+ | `gateway` | Creates an API Gateway + default Lambda | Yes — sets the default Lambda for all routes inside |
180
+ | `function` | Routes to a different Lambda | Yes — overrides the gateway's default |
181
+ | `namespace` | Adds path prefix + controller module | No — Rails-like code organization only |
182
+ | `scope` | Flexible path/module/auth grouping | No — grouping and shared options only |
183
+
184
+ **Example combining all four:**
185
+
186
+ ```ruby
187
+ Belt.application.routes.draw do
188
+ gateway :api, auth: :cognito do
189
+ resources :posts # → lambda: api, path: /posts, controller: posts
190
+
191
+ namespace :admin do
192
+ resources :users # → lambda: api, path: /admin/users, controller: admin/users
193
+ end
194
+
195
+ function :worker do
196
+ resources :jobs # → lambda: worker, path: /jobs, controller: jobs
197
+
198
+ namespace :internal do
199
+ resources :tasks # → lambda: worker, path: /internal/tasks, controller: internal/tasks
200
+ end
201
+ end
202
+
203
+ scope path: 'v2', module: 'legacy' do
204
+ resources :widgets # → lambda: api, path: /v2/widgets, controller: legacy/widgets
205
+ end
206
+ end
207
+ end
208
+ ```
209
+
210
+ **Key point:** `namespace` and `scope` are purely organizational — they affect URL paths and controller module resolution but never change which Lambda handles the request. Use `function` when you need routes to go to a different Lambda.
211
+
173
212
  ## BeltController Features
174
213
 
175
214
  ### Callbacks
@@ -423,7 +462,7 @@ The command expects `infrastructure/routes.tf.rb` in the current working directo
423
462
 
424
463
  ```ruby
425
464
  Belt.application.routes.draw do
426
- namespace :api do
465
+ gateway :api do
427
466
  resources :posts, only: [:index, :show, :create, :destroy]
428
467
  resource :profile, only: [:show, :update]
429
468
  get "health", action: :health
@@ -7,22 +7,26 @@ module Belt
7
7
  # Routes incoming requests to controllers based on a route manifest.
8
8
  #
9
9
  # Usage:
10
- # ROUTER = Belt::ActionRouter.new(routes: MY_ROUTES, namespace: "api")
10
+ # ROUTER = Belt::ActionRouter.new(routes: MY_ROUTES, gateway: "api")
11
11
  # response = ROUTER.route(event: event, body: body)
12
12
  #
13
13
  class ActionRouter
14
14
  class RouteNotFound < StandardError; end
15
15
 
16
- def initialize(routes:, namespace:)
17
- @namespace = namespace.to_s
18
- @namespace_module_name = "#{@namespace.split('_').map(&:capitalize).join}Controllers"
16
+ def initialize(routes:, gateway: nil, namespace: nil)
17
+ # Accept `gateway:` (preferred) or legacy `namespace:` keyword
18
+ gw = gateway || namespace
19
+ raise ArgumentError, 'Belt::ActionRouter requires a gateway: (or legacy namespace:) argument' unless gw
20
+
21
+ @gateway = gw.to_s
22
+ @gateway_module_name = "#{@gateway.split('_').map(&:capitalize).join}Controllers"
19
23
  @routes = build_route_table(routes)
20
24
  end
21
25
 
22
26
  def route(event:, body:)
23
27
  method = event['httpMethod']
24
28
  full_path = event['path']
25
- match_path = strip_namespace_prefix(full_path)
29
+ match_path = strip_gateway_prefix(full_path)
26
30
 
27
31
  route_info = find_route(method, match_path)
28
32
 
@@ -56,10 +60,10 @@ module Belt
56
60
 
57
61
  private
58
62
 
59
- def strip_namespace_prefix(path)
63
+ def strip_gateway_prefix(path)
60
64
  return '/' if path.nil?
61
65
 
62
- prefix = "/#{@namespace}"
66
+ prefix = "/#{@gateway}"
63
67
  if path.start_with?(prefix)
64
68
  stripped = path.sub(prefix, '')
65
69
  stripped.empty? ? '/' : stripped
@@ -127,15 +131,15 @@ module Belt
127
131
  return false if name.start_with?('/') || name.end_with?('/')
128
132
  return false if name.include?('\\')
129
133
 
130
- # Allow only lowercase letters, digits, underscores, and a single forward slash for nesting
134
+ # Allow only lowercase letters, digits, underscores, and forward slashes for nesting
131
135
  name.match?(%r{\A[a-z][a-z0-9_]*(/[a-z][a-z0-9_]*)?\z})
132
136
  end
133
137
 
134
138
  def resolve_controller(controller_name)
135
- # Try namespace module first (app's own controllers)
139
+ # Try gateway module first (app's own controllers)
136
140
  begin
137
- namespace_module = Object.const_get(@namespace_module_name)
138
- return resolve_from_module(namespace_module, controller_name)
141
+ gateway_module = Object.const_get(@gateway_module_name)
142
+ return resolve_from_module(gateway_module, controller_name)
139
143
  rescue NameError
140
144
  # Fall through to controller_paths lookup
141
145
  end
@@ -144,13 +148,13 @@ module Belt
144
148
  resolve_from_paths(controller_name)
145
149
  end
146
150
 
147
- def resolve_from_module(namespace_module, controller_name)
151
+ def resolve_from_module(gateway_module, controller_name)
148
152
  if controller_name.include?('/')
149
153
  parts = controller_name.split('/')
150
- parent = namespace_module.const_get(parts[0].split('_').map(&:capitalize).join)
154
+ parent = gateway_module.const_get(parts[0].split('_').map(&:capitalize).join)
151
155
  parent.const_get("#{parts[1].split('_').map(&:capitalize).join}Controller")
152
156
  else
153
- namespace_module.const_get("#{controller_name.split('_').map(&:capitalize).join}Controller")
157
+ gateway_module.const_get("#{controller_name.split('_').map(&:capitalize).join}Controller")
154
158
  end
155
159
  end
156
160
 
@@ -176,9 +180,9 @@ module Belt
176
180
  class_name = "#{controller_name.split(%r{[_/]}).map(&:capitalize).join}Controller"
177
181
  return Object.const_get(class_name) if Object.const_defined?(class_name)
178
182
 
179
- # Try under namespace module (e.g., BrablogControllers::PostsController)
180
- if Object.const_defined?(@namespace_module_name)
181
- ns = Object.const_get(@namespace_module_name)
183
+ # Try under gateway module (e.g., ApiControllers::PostsController)
184
+ if Object.const_defined?(@gateway_module_name)
185
+ ns = Object.const_get(@gateway_module_name)
182
186
  return ns.const_get(class_name) if ns.const_defined?(class_name)
183
187
  end
184
188
  end
@@ -3,12 +3,14 @@
3
3
  module Belt
4
4
  module CLI
5
5
  module AppDetection
6
- # Detects the primary namespace from the route definitions.
6
+ # Detects the primary gateway from the route definitions.
7
7
  # Used by generators to determine controller directory and route file naming.
8
8
  def detect_namespace
9
9
  routes_file = find_routes_file_path
10
10
  if routes_file && File.exist?(routes_file)
11
- match = File.read(routes_file).match(/namespace :(\w+)/)
11
+ content = File.read(routes_file)
12
+ # Prefer `gateway :name` but fall back to legacy `namespace :name` at top-level
13
+ match = content.match(/^\s*gateway :(\w+)/) || content.match(/^\s*namespace :(\w+)/)
12
14
  return match[1] if match
13
15
  end
14
16
  File.basename(Dir.pwd)
@@ -101,14 +101,48 @@ module Belt
101
101
  write_cognito_tf
102
102
  write_cognito_outputs_tf
103
103
  patch_main_tf
104
+ patch_env_outputs
104
105
  generate_frontend_auth if frontend?
105
106
 
106
107
  puts "\n✓ Auth generated!"
108
+ print_next_steps
109
+ end
110
+
111
+ def print_next_steps
107
112
  puts "\nNext steps:"
108
- puts ' 1. Review infrastructure/modules/app/cognito.tf'
109
- puts ' 2. Customize password policy, MFA, or Lambda triggers as needed'
110
- puts ' 3. Run `belt apply <env>` to deploy'
111
- puts ' 4. Create your account: aws cognito-idp admin-create-user ...' unless @signup
113
+ puts ' 1. Add auth: :cognito to your routes namespace:'
114
+ puts ''
115
+ puts ' namespace :api, auth: :cognito do'
116
+ puts ' # your resources...'
117
+ puts ' end'
118
+ puts ''
119
+ if frontend?
120
+ puts ' 2. Wire auth into your frontend/src/App.jsx:'
121
+ puts ''
122
+ puts " import Login from './pages/auth/Login'"
123
+ puts " import ProtectedRoute from './components/ProtectedRoute'"
124
+ puts ''
125
+ puts ' // Add login route:'
126
+ puts ' <Route path="/login" element={<Login onLogin={() => window.location.href = \'/\'} />} />'
127
+ puts ''
128
+ puts ' // Wrap protected routes:'
129
+ puts ' <Route path="/*" element={<ProtectedRoute><YourApp /></ProtectedRoute>} />'
130
+ puts ''
131
+ puts ' 3. Deploy: belt deploy'
132
+ print_create_user_step(4) unless @signup
133
+ else
134
+ puts ' 2. Deploy: belt deploy'
135
+ print_create_user_step(3) unless @signup
136
+ end
137
+ end
138
+
139
+ def print_create_user_step(step_num)
140
+ puts " #{step_num}. Create your account:"
141
+ puts ' aws cognito-idp admin-create-user \\'
142
+ puts ' --user-pool-id <pool-id-from-terraform-output> \\'
143
+ puts ' --username your@email.com \\'
144
+ puts ' --temporary-password TempPass123 \\'
145
+ puts ' --message-action SUPPRESS'
112
146
  end
113
147
 
114
148
  def remove
@@ -167,6 +201,7 @@ module Belt
167
201
  pages_dir = 'frontend/src/pages/auth'
168
202
  FileUtils.mkdir_p(pages_dir)
169
203
  copy_frontend_file(frontend_template_dir, 'Login.jsx', File.join(pages_dir, 'Login.jsx'))
204
+ copy_frontend_file(frontend_template_dir, 'auth.css', File.join(pages_dir, 'auth.css'))
170
205
  if @signup
171
206
  # Generate auth pages
172
207
  copy_frontend_file(frontend_template_dir, 'SignUp.jsx', File.join(pages_dir, 'SignUp.jsx'))
@@ -244,6 +279,42 @@ module Belt
244
279
  insert_cognito_into_resource(content, main_tf, arn_value)
245
280
  end
246
281
 
282
+ def patch_env_outputs
283
+ env_dirs = Dir.glob('infrastructure/*/outputs.tf')
284
+ .reject { |f| f.include?('modules') }
285
+
286
+ env_dirs.each do |outputs_file|
287
+ content = File.read(outputs_file)
288
+
289
+ next if content.include?('cognito_user_pool_id')
290
+
291
+ cognito_outputs = @pools.map do |pool|
292
+ suffix = pool[:suffix]
293
+ <<~HCL
294
+
295
+ output "cognito_user_pool_id#{suffix}" {
296
+ description = "Cognito User Pool ID#{pool[:label]}"
297
+ value = module.app.cognito_user_pool_id#{suffix}
298
+ }
299
+
300
+ output "cognito_client_id#{suffix}" {
301
+ description = "Cognito User Pool Client ID#{pool[:label]}"
302
+ value = module.app.cognito_client_id#{suffix}
303
+ }
304
+
305
+ output "cognito_region" {
306
+ description = "AWS region for Cognito"
307
+ value = var.aws_region
308
+ }
309
+ HCL
310
+ end.join
311
+
312
+ File.write(outputs_file, content + cognito_outputs)
313
+ env_name = File.basename(File.dirname(outputs_file))
314
+ puts " update #{outputs_file} (added cognito outputs for #{env_name})"
315
+ end
316
+ end
317
+
247
318
  def build_arn_value(arns)
248
319
  if arns.length == 1
249
320
  "[#{arns.first}]"
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'optparse'
4
+ require 'stringio'
4
5
  require_relative 'environment_config'
5
6
 
6
7
  module Belt
@@ -59,7 +60,10 @@ module Belt
59
60
  boot_app
60
61
  puts banner
61
62
  ARGV.clear
62
- require 'irb'
63
+ suppress_warnings do
64
+ require 'irb'
65
+ require 'rdoc'
66
+ end
63
67
  IRB.start
64
68
  end
65
69
 
@@ -72,7 +76,7 @@ module Belt
72
76
  end
73
77
 
74
78
  def boot_app
75
- require 'bundler/setup'
79
+ suppress_warnings { require 'bundler/setup' }
76
80
 
77
81
  environment_file = File.join(Belt.root, 'lambda', 'config', 'environment.rb')
78
82
  if File.exist?(environment_file)
@@ -132,6 +136,17 @@ module Belt
132
136
  result.inspect
133
137
  end
134
138
  end
139
+
140
+ def suppress_warnings
141
+ original_verbose = $VERBOSE
142
+ $VERBOSE = nil
143
+ original_stderr = $stderr
144
+ $stderr = StringIO.new
145
+ yield
146
+ ensure
147
+ $stderr = original_stderr
148
+ $VERBOSE = original_verbose
149
+ end
135
150
  end
136
151
  end
137
152
  end
@@ -145,9 +145,9 @@ module Belt
145
145
 
146
146
  def run
147
147
  validate!
148
- env_dir = File.join(@infra_dir, @env)
149
-
150
148
  load_and_apply_env_config!
149
+ run_preflight_checks!
150
+ env_dir = File.join(@infra_dir, @env)
151
151
 
152
152
  puts "belt → deploying #{@env} (in #{env_dir}/)\n\n"
153
153
 
@@ -263,6 +263,14 @@ module Belt
263
263
  "Create it with: belt generate environment #{@env}"
264
264
  end
265
265
 
266
+ def run_preflight_checks!
267
+ require_relative 'doctor_command'
268
+ doctor = DoctorCommand.new(preflight: true)
269
+ return if doctor.run
270
+
271
+ abort 'Deploy blocked. Fix the issues above, then try again.'
272
+ end
273
+
266
274
  def validate_aws!
267
275
  stdout, status = Open3.capture2('aws', 'sts', 'get-caller-identity', '--output', 'json')
268
276
  unless status.success?
@@ -10,7 +10,7 @@ require_relative '../inflector'
10
10
  module Belt
11
11
  module CLI
12
12
  class DestroyCommand
13
- GENERATORS = %w[scaffold resource model controller environment frontend views auth].freeze
13
+ GENERATORS = %w[scaffold resource model controller environment frontend views index auth].freeze
14
14
 
15
15
  include AppDetection
16
16
 
@@ -61,6 +61,8 @@ module Belt
61
61
  exit 1
62
62
  end
63
63
  new(generator, name, args.map { |a| parse_field(a) }).destroy
64
+ when 'index'
65
+ Belt::CLI::IndexCommand.run(['remove'] + args)
64
66
  else
65
67
  name = args.shift
66
68
  if name.nil? || name.empty?