belt 0.2.19 → 0.2.20
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/lib/belt/cli/auth_command.rb +75 -4
- data/lib/belt/cli/console_command.rb +17 -2
- data/lib/belt/cli/deploy_command.rb +9 -0
- data/lib/belt/cli/destroy_command.rb +3 -1
- data/lib/belt/cli/doctor_command.rb +166 -22
- data/lib/belt/cli/generate_command.rb +24 -31
- data/lib/belt/cli/index_command.rb +192 -0
- data/lib/belt/cli/lambda_config_command.rb +2 -1
- data/lib/belt/cli/routes_command/route_inference.rb +4 -3
- data/lib/belt/cli/routes_command.rb +0 -19
- data/lib/belt/cli/views_command.rb +3 -9
- data/lib/belt/cli.rb +1 -0
- data/lib/belt/inflector.rb +8 -0
- data/lib/belt/route_dsl.rb +2 -1
- data/lib/belt/version.rb +1 -1
- data/lib/templates/generate/auth/frontend/ConfirmEmail.jsx +1 -1
- data/lib/templates/generate/auth/frontend/Login.jsx +2 -4
- data/lib/templates/generate/auth/frontend/SignUp.jsx +1 -1
- data/lib/templates/generate/auth/frontend/auth.css +157 -0
- data/lib/templates/generate/model.rb.erb +4 -6
- data/lib/templates/new_app/config/lambda/api.yml.erb +1 -0
- metadata +31 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 983c166381b409ee19c2a23c1439be35180892299af76570097d206dea1ba1a4
|
|
4
|
+
data.tar.gz: f7888dca2b10805bed099da525b968b90e511c1b8381990ba85ab80dc23a9263
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 253f76decf02f960c12011b95570b04213c33800eecfaf70415071f8a2792cae2480a71251cc0ec1b565b837b1c9229f6522c6452124f63560334c6cb6b838d4
|
|
7
|
+
data.tar.gz: 5393e1c8cdef68b50dee5bca41a299d02560ecc9e65718e8ed45e2cb09b5ffabaf74eefc11b47e59f35fd7b78b602b866db7028eae82246e0da76b14910eca53
|
|
@@ -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.
|
|
109
|
-
puts '
|
|
110
|
-
puts '
|
|
111
|
-
puts '
|
|
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
|
-
|
|
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,6 +145,7 @@ module Belt
|
|
|
145
145
|
|
|
146
146
|
def run
|
|
147
147
|
validate!
|
|
148
|
+
run_preflight_checks!
|
|
148
149
|
env_dir = File.join(@infra_dir, @env)
|
|
149
150
|
|
|
150
151
|
load_and_apply_env_config!
|
|
@@ -263,6 +264,14 @@ module Belt
|
|
|
263
264
|
"Create it with: belt generate environment #{@env}"
|
|
264
265
|
end
|
|
265
266
|
|
|
267
|
+
def run_preflight_checks!
|
|
268
|
+
require_relative 'doctor_command'
|
|
269
|
+
doctor = DoctorCommand.new(preflight: true)
|
|
270
|
+
return if doctor.run
|
|
271
|
+
|
|
272
|
+
abort 'Deploy blocked. Fix the issues above, then try again.'
|
|
273
|
+
end
|
|
274
|
+
|
|
266
275
|
def validate_aws!
|
|
267
276
|
stdout, status = Open3.capture2('aws', 'sts', 'get-caller-identity', '--output', 'json')
|
|
268
277
|
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?
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'json'
|
|
4
4
|
require 'open3'
|
|
5
|
+
require_relative '../../belt/inflector'
|
|
5
6
|
|
|
6
7
|
module Belt
|
|
7
8
|
module CLI
|
|
@@ -17,13 +18,14 @@ module Belt
|
|
|
17
18
|
|
|
18
19
|
def self.run(args)
|
|
19
20
|
verbose = args.include?('--verbose') || args.include?('-v')
|
|
21
|
+
preflight = args.include?('--preflight')
|
|
20
22
|
|
|
21
23
|
if args.include?('--help') || args.include?('-h')
|
|
22
24
|
puts usage
|
|
23
25
|
exit 0
|
|
24
26
|
end
|
|
25
27
|
|
|
26
|
-
new(verbose: verbose).run
|
|
28
|
+
new(verbose: verbose, preflight: preflight).run
|
|
27
29
|
end
|
|
28
30
|
|
|
29
31
|
def self.usage
|
|
@@ -33,24 +35,50 @@ module Belt
|
|
|
33
35
|
Check system dependencies and AWS configuration for Belt.
|
|
34
36
|
|
|
35
37
|
Options:
|
|
36
|
-
-v, --verbose
|
|
37
|
-
-
|
|
38
|
+
-v, --verbose Show detailed output for each check
|
|
39
|
+
--preflight Run only deploy-critical checks (indexes, credentials)
|
|
40
|
+
-h, --help Show this help
|
|
38
41
|
USAGE
|
|
39
42
|
end
|
|
40
43
|
|
|
41
|
-
def initialize(verbose: false)
|
|
44
|
+
def initialize(verbose: false, preflight: false)
|
|
42
45
|
@verbose = verbose
|
|
46
|
+
@preflight = preflight
|
|
43
47
|
@issues = []
|
|
44
48
|
@warnings = []
|
|
45
49
|
end
|
|
46
50
|
|
|
47
|
-
def run
|
|
51
|
+
def run # rubocop:disable Naming/PredicateMethod
|
|
52
|
+
if @preflight
|
|
53
|
+
run_preflight
|
|
54
|
+
else
|
|
55
|
+
run_full
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
@issues.empty?
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Returns true if all preflight checks pass, false otherwise.
|
|
62
|
+
def run_preflight
|
|
63
|
+
check_aws_identity_quiet
|
|
64
|
+
check_table_indexes
|
|
65
|
+
check_cognito_auth
|
|
66
|
+
return if @issues.empty?
|
|
67
|
+
|
|
68
|
+
puts "\nPreflight checks failed:\n"
|
|
69
|
+
@issues.each { |i| puts " ✗ #{i}" }
|
|
70
|
+
puts ''
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def run_full
|
|
48
74
|
puts "Belt Doctor\n"
|
|
49
75
|
puts "Checking your system for Belt prerequisites...\n\n"
|
|
50
76
|
|
|
51
77
|
check_tools
|
|
52
78
|
check_aws_credentials
|
|
53
79
|
check_aws_identity
|
|
80
|
+
check_table_indexes
|
|
81
|
+
check_cognito_auth
|
|
54
82
|
|
|
55
83
|
puts ''
|
|
56
84
|
print_summary
|
|
@@ -132,28 +160,144 @@ module Belt
|
|
|
132
160
|
print_ok('Authentication', "account #{account}")
|
|
133
161
|
puts " ARN: #{arn}" if @verbose
|
|
134
162
|
else
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
163
|
+
handle_aws_identity_error(output.strip)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def check_aws_identity_quiet
|
|
168
|
+
_, status = Open3.capture2e('aws', 'sts', 'get-caller-identity')
|
|
169
|
+
return if status.success?
|
|
170
|
+
|
|
171
|
+
@issues << 'AWS credentials invalid or expired — run `aws sso login`'
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def handle_aws_identity_error(error)
|
|
175
|
+
if error.include?('ExpiredToken') || error.include?('expired')
|
|
176
|
+
print_fail('Authentication', 'credentials expired')
|
|
177
|
+
puts ' Run: aws sso login'
|
|
178
|
+
@issues << 'AWS credentials are expired — run `aws sso login`'
|
|
179
|
+
elsif error.include?('InvalidClientTokenId') || error.include?('SignatureDoesNotMatch')
|
|
180
|
+
print_fail('Authentication', 'invalid credentials')
|
|
181
|
+
puts ' Your access key or secret is incorrect.'
|
|
182
|
+
puts ' Run: aws configure'
|
|
183
|
+
@issues << 'AWS credentials are invalid'
|
|
184
|
+
elsif error.include?('Could not connect') || error.include?('Unable to locate credentials')
|
|
185
|
+
print_fail('Authentication', 'unable to authenticate')
|
|
186
|
+
puts ' Run: aws configure sso # or set AWS_PROFILE'
|
|
187
|
+
@issues << 'Unable to authenticate with AWS'
|
|
188
|
+
else
|
|
189
|
+
print_fail('Authentication', 'failed')
|
|
190
|
+
puts " #{error.lines.first&.strip}" if error.length.positive?
|
|
191
|
+
@issues << 'AWS authentication failed'
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def check_table_indexes
|
|
196
|
+
return unless Belt.root?
|
|
197
|
+
|
|
198
|
+
models_dir = File.join(Belt.root, 'lambda/models')
|
|
199
|
+
dynamodb_tf = File.join(Belt.root, 'infrastructure/modules/app/dynamodb.tf')
|
|
200
|
+
|
|
201
|
+
return unless Dir.exist?(models_dir)
|
|
202
|
+
|
|
203
|
+
puts ''
|
|
204
|
+
puts '── Tables & Indexes ──'
|
|
205
|
+
|
|
206
|
+
unless File.exist?(dynamodb_tf)
|
|
207
|
+
print_warn('dynamodb.tf', 'not found — run `belt setup tables` to generate')
|
|
208
|
+
@warnings << 'dynamodb.tf not generated — run `belt setup tables`'
|
|
209
|
+
return
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
tf_content = File.read(dynamodb_tf)
|
|
213
|
+
model_files = Dir.glob(File.join(models_dir, '*.rb'))
|
|
214
|
+
.reject { |f| File.basename(f) == 'application_record.rb' }
|
|
215
|
+
|
|
216
|
+
model_files.each do |file|
|
|
217
|
+
check_model_indexes(file, tf_content)
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def check_model_indexes(file, tf_content)
|
|
222
|
+
content = File.read(file)
|
|
223
|
+
class_match = content.match(/^class\s+(\w+)\s*<\s*ApplicationRecord/)
|
|
224
|
+
return unless class_match
|
|
225
|
+
|
|
226
|
+
model_name = Belt::Inflector.underscore(class_match[1])
|
|
227
|
+
table_name = Belt::Inflector.pluralize(model_name)
|
|
228
|
+
|
|
229
|
+
# Check if table exists in dynamodb.tf
|
|
230
|
+
unless tf_content.include?("resource \"aws_dynamodb_table\" \"#{table_name}\"")
|
|
231
|
+
print_fail(table_name, 'table not in dynamodb.tf')
|
|
232
|
+
@issues << "Table '#{table_name}' missing from dynamodb.tf — run `belt setup tables`"
|
|
233
|
+
return
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Extract expected indexes from belongs_to
|
|
237
|
+
expected_indexes = extract_expected_indexes(content)
|
|
238
|
+
return if expected_indexes.empty?
|
|
239
|
+
|
|
240
|
+
# Check each expected index exists in the TF file
|
|
241
|
+
# Scope check to this table's resource block
|
|
242
|
+
table_block = tf_content[/resource "aws_dynamodb_table" "#{table_name}" \{.*?^\}/m]
|
|
243
|
+
return unless table_block
|
|
244
|
+
|
|
245
|
+
missing = []
|
|
246
|
+
expected_indexes.each do |idx|
|
|
247
|
+
if table_block.include?("name = \"#{idx[:name]}\"")
|
|
248
|
+
print_ok(table_name, idx[:name]) if @verbose
|
|
149
249
|
else
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
250
|
+
missing << idx
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
if missing.empty?
|
|
255
|
+
print_ok(table_name, "#{expected_indexes.size} index#{'es' if expected_indexes.size > 1} configured")
|
|
256
|
+
else
|
|
257
|
+
missing.each do |idx|
|
|
258
|
+
print_fail(table_name, "missing #{idx[:name]} (required by belongs_to :#{idx[:association]})")
|
|
259
|
+
@issues << "Table '#{table_name}' missing index '#{idx[:name]}' " \
|
|
260
|
+
'— run `belt setup tables` then `belt deploy`'
|
|
153
261
|
end
|
|
154
262
|
end
|
|
155
263
|
end
|
|
156
264
|
|
|
265
|
+
def extract_expected_indexes(content)
|
|
266
|
+
indexes = []
|
|
267
|
+
content.lines.reject { |line| line.strip.start_with?('#') }.join
|
|
268
|
+
.scan(/belongs_to\s+:(\w+)/) do |match|
|
|
269
|
+
association_name = match[0]
|
|
270
|
+
indexes << {
|
|
271
|
+
name: "#{Belt::Inflector.classify(association_name)}Index",
|
|
272
|
+
association: association_name
|
|
273
|
+
}
|
|
274
|
+
end
|
|
275
|
+
indexes
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def check_cognito_auth
|
|
279
|
+
return unless Belt.root?
|
|
280
|
+
|
|
281
|
+
routes_file = Belt.routes_file
|
|
282
|
+
return unless routes_file
|
|
283
|
+
|
|
284
|
+
content = File.read(routes_file)
|
|
285
|
+
|
|
286
|
+
# Check if any routes use auth: :cognito
|
|
287
|
+
uses_cognito = content.include?('auth: :cognito')
|
|
288
|
+
return unless uses_cognito
|
|
289
|
+
|
|
290
|
+
# Check if cognito.tf exists
|
|
291
|
+
cognito_tf = File.join(Belt.root, 'infrastructure/modules/app/cognito.tf')
|
|
292
|
+
return if File.exist?(cognito_tf)
|
|
293
|
+
|
|
294
|
+
puts ''
|
|
295
|
+
puts '── Authentication ──'
|
|
296
|
+
print_warn('Cognito', 'routes use auth: :cognito but no cognito.tf found')
|
|
297
|
+
puts ' Run: belt generate auth'
|
|
298
|
+
@warnings << 'Routes use auth: :cognito but no Cognito pool is configured — run `belt generate auth`'
|
|
299
|
+
end
|
|
300
|
+
|
|
157
301
|
def print_summary
|
|
158
302
|
if @issues.empty? && @warnings.empty?
|
|
159
303
|
puts '✓ All checks passed — you\'re ready to belt!'
|
|
@@ -15,7 +15,8 @@ module Belt
|
|
|
15
15
|
module CLI
|
|
16
16
|
class GenerateCommand
|
|
17
17
|
TEMPLATE_DIR = File.expand_path('../../templates/generate', __dir__)
|
|
18
|
-
|
|
18
|
+
|
|
19
|
+
GENERATORS = %w[scaffold resource model controller environment frontend views index auth].freeze
|
|
19
20
|
|
|
20
21
|
include AppDetection
|
|
21
22
|
|
|
@@ -127,6 +128,8 @@ module Belt
|
|
|
127
128
|
|
|
128
129
|
return Belt::CLI::ViewsCommand.run(args) if generator == 'views'
|
|
129
130
|
|
|
131
|
+
return Belt::CLI::IndexCommand.run(['add'] + args) if generator == 'index'
|
|
132
|
+
|
|
130
133
|
return Belt::CLI::AuthCommand.run(args) if generator == 'auth'
|
|
131
134
|
|
|
132
135
|
name = args.shift
|
|
@@ -290,7 +293,9 @@ module Belt
|
|
|
290
293
|
when 'model'
|
|
291
294
|
check_model_collision! unless @force
|
|
292
295
|
generate_model_standalone
|
|
293
|
-
when 'controller'
|
|
296
|
+
when 'controller'
|
|
297
|
+
generate_controller
|
|
298
|
+
inject_routes
|
|
294
299
|
end
|
|
295
300
|
end
|
|
296
301
|
|
|
@@ -351,18 +356,6 @@ module Belt
|
|
|
351
356
|
inject_parent_associations
|
|
352
357
|
sync_tables
|
|
353
358
|
generate_views_if_frontend
|
|
354
|
-
puts "\n✓ Scaffold '#{@singular_name}' generated!"
|
|
355
|
-
puts "\nFiles created/updated:"
|
|
356
|
-
puts " lambda/models/#{@singular_name}.rb"
|
|
357
|
-
puts " lambda/controllers/#{@app_name}/#{@resource_name}_controller.rb"
|
|
358
|
-
puts " #{find_routes_file_path || 'config/routes.rb'} (updated)"
|
|
359
|
-
puts " #{find_contracts_file_path || 'config/contracts.rb'} (updated)"
|
|
360
|
-
puts " lambda/lib/routes/#{@app_name}_routes.rb (updated)"
|
|
361
|
-
puts " frontend/src/pages/#{@resource_name}/ (views)" if Dir.exist?('frontend/src')
|
|
362
|
-
@references.each do |ref|
|
|
363
|
-
parent_model_path = "lambda/models/#{ref[:referenced_model]}.rb"
|
|
364
|
-
puts " #{parent_model_path} (updated — added has_many)" if File.exist?(parent_model_path)
|
|
365
|
-
end
|
|
366
359
|
end
|
|
367
360
|
|
|
368
361
|
def generate_model_standalone
|
|
@@ -386,25 +379,26 @@ module Belt
|
|
|
386
379
|
|
|
387
380
|
def inject_routes
|
|
388
381
|
routes_file = find_routes_file_path
|
|
389
|
-
return unless routes_file && File.exist?(routes_file)
|
|
390
382
|
|
|
391
|
-
|
|
392
|
-
|
|
383
|
+
if routes_file && File.exist?(routes_file)
|
|
384
|
+
content = File.read(routes_file)
|
|
385
|
+
tables_arg = @fields.any? ? ", tables: [:#{@resource_name}]" : ''
|
|
393
386
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
387
|
+
# If this resource has a reference to a parent that already has routes, nest it
|
|
388
|
+
parent_ref = @references.find do |ref|
|
|
389
|
+
parent_resource = Belt::Inflector.pluralize(ref[:referenced_model])
|
|
390
|
+
content.match?(/resources :#{Regexp.escape(parent_resource)}\b/)
|
|
391
|
+
end
|
|
399
392
|
|
|
400
|
-
|
|
401
|
-
|
|
393
|
+
if parent_ref
|
|
394
|
+
inject_nested_route(content, routes_file, parent_ref, tables_arg)
|
|
395
|
+
else
|
|
396
|
+
inject_top_level_route(content, routes_file, tables_arg)
|
|
397
|
+
end
|
|
402
398
|
else
|
|
403
|
-
|
|
399
|
+
# Legacy projects without config/routes.rb — update the manifest directly
|
|
400
|
+
inject_route_manifest
|
|
404
401
|
end
|
|
405
|
-
|
|
406
|
-
# Also update route manifest
|
|
407
|
-
inject_route_manifest
|
|
408
402
|
end
|
|
409
403
|
|
|
410
404
|
def inject_nested_route(content, routes_file, parent_ref, tables_arg)
|
|
@@ -596,8 +590,7 @@ module Belt
|
|
|
596
590
|
next unless File.exist?(parent_model_path)
|
|
597
591
|
|
|
598
592
|
content = File.read(parent_model_path)
|
|
599
|
-
has_many_line = " has_many :#{@resource_name}
|
|
600
|
-
"index: '#{Belt::Inflector.classify(ref[:referenced_model])}Index'"
|
|
593
|
+
has_many_line = " has_many :#{@resource_name}"
|
|
601
594
|
|
|
602
595
|
next if content.include?("has_many :#{@resource_name}")
|
|
603
596
|
|
|
@@ -632,7 +625,7 @@ module Belt
|
|
|
632
625
|
return unless Dir.exist?('frontend/src')
|
|
633
626
|
return if @skip_views
|
|
634
627
|
|
|
635
|
-
Belt::CLI::ViewsCommand.new(@name, @fields, force: @force).generate
|
|
628
|
+
Belt::CLI::ViewsCommand.new(@name, @fields, force: @force, quiet: true).generate
|
|
636
629
|
end
|
|
637
630
|
end
|
|
638
631
|
end
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'tables_command'
|
|
4
|
+
require_relative '../inflector'
|
|
5
|
+
|
|
6
|
+
module Belt
|
|
7
|
+
module CLI
|
|
8
|
+
class IndexCommand
|
|
9
|
+
MODULE_DIR = 'infrastructure/modules/app'
|
|
10
|
+
DYNAMODB_TF = File.join(MODULE_DIR, 'dynamodb.tf')
|
|
11
|
+
|
|
12
|
+
def self.run(args)
|
|
13
|
+
action = args.shift
|
|
14
|
+
|
|
15
|
+
case action
|
|
16
|
+
when 'add', nil
|
|
17
|
+
add(args)
|
|
18
|
+
when 'remove', 'rm'
|
|
19
|
+
remove(args)
|
|
20
|
+
when '--help', '-h'
|
|
21
|
+
puts usage
|
|
22
|
+
else
|
|
23
|
+
# Treat first arg as table name if no subcommand
|
|
24
|
+
add([action] + args)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.usage
|
|
29
|
+
<<~USAGE
|
|
30
|
+
Usage: belt generate index <table> <IndexName> --partition-key <key> [--sort-key <key>]
|
|
31
|
+
belt destroy index <table> <IndexName>
|
|
32
|
+
|
|
33
|
+
Add or remove a Global Secondary Index (GSI) from dynamodb.tf.
|
|
34
|
+
|
|
35
|
+
Examples:
|
|
36
|
+
belt generate index messages ConversationIndex --partition-key conversation_id
|
|
37
|
+
belt generate index messages RecentByUserIndex --partition-key user_id --sort-key created_at
|
|
38
|
+
belt destroy index messages ConversationIndex
|
|
39
|
+
|
|
40
|
+
Note: After modifying indexes, run `belt deploy` to apply changes to AWS.
|
|
41
|
+
Adding a GSI to an existing table takes ~5-10 minutes (AWS limitation).
|
|
42
|
+
USAGE
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.add(args)
|
|
46
|
+
table, index_name, partition_key, sort_key = parse_add_args(args)
|
|
47
|
+
new(table, index_name, partition_key: partition_key, sort_key: sort_key).add
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.remove(args)
|
|
51
|
+
table = args.shift
|
|
52
|
+
index_name = args.shift
|
|
53
|
+
|
|
54
|
+
if table.nil? || index_name.nil?
|
|
55
|
+
abort "Usage: belt destroy index <table> <IndexName>\n\n" \
|
|
56
|
+
'Example: belt destroy index messages ConversationIndex'
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
new(table, index_name).remove
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.parse_add_args(args)
|
|
63
|
+
table = args.shift
|
|
64
|
+
index_name = args.shift
|
|
65
|
+
partition_key = nil
|
|
66
|
+
sort_key = nil
|
|
67
|
+
|
|
68
|
+
i = 0
|
|
69
|
+
while i < args.length
|
|
70
|
+
case args[i]
|
|
71
|
+
when '--partition-key', '-p'
|
|
72
|
+
i += 1
|
|
73
|
+
partition_key = args[i]
|
|
74
|
+
when '--sort-key', '-s'
|
|
75
|
+
i += 1
|
|
76
|
+
sort_key = args[i]
|
|
77
|
+
end
|
|
78
|
+
i += 1
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
if table.nil? || index_name.nil? || partition_key.nil?
|
|
82
|
+
abort "Usage: belt generate index <table> <IndexName> --partition-key <key> [--sort-key <key>]\n\n" \
|
|
83
|
+
'Example: belt generate index messages ConversationIndex --partition-key conversation_id'
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
[table, index_name, partition_key, sort_key]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def initialize(table, index_name, partition_key: nil, sort_key: nil)
|
|
90
|
+
@table = table
|
|
91
|
+
@index_name = index_name
|
|
92
|
+
@partition_key = partition_key
|
|
93
|
+
@sort_key = sort_key
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def add
|
|
97
|
+
validate_tf_exists!
|
|
98
|
+
|
|
99
|
+
content = File.read(DYNAMODB_TF)
|
|
100
|
+
table_resource = "aws_dynamodb_table\" \"#{@table}\""
|
|
101
|
+
|
|
102
|
+
unless content.include?(table_resource)
|
|
103
|
+
abort "Error: Table '#{@table}' not found in #{DYNAMODB_TF}.\n" \
|
|
104
|
+
'Run `belt setup tables` first to generate the table.'
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
if content.include?("name = \"#{@index_name}\"")
|
|
108
|
+
puts " skip #{@index_name} (already exists on #{@table})"
|
|
109
|
+
return
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
dynamo_pk = Belt::Inflector.camelize_lower(@partition_key)
|
|
113
|
+
dynamo_sk = @sort_key ? Belt::Inflector.camelize_lower(@sort_key) : nil
|
|
114
|
+
|
|
115
|
+
# Build the GSI block
|
|
116
|
+
gsi_block = build_gsi_block(dynamo_pk, dynamo_sk)
|
|
117
|
+
|
|
118
|
+
# Build attribute blocks for new keys
|
|
119
|
+
attr_blocks = build_attribute_blocks(content, dynamo_pk, dynamo_sk)
|
|
120
|
+
|
|
121
|
+
# Insert into the table resource
|
|
122
|
+
insert_gsi(content, gsi_block, attr_blocks)
|
|
123
|
+
|
|
124
|
+
puts " create #{@index_name} on #{@table} (partition: #{dynamo_pk}#{", sort: #{dynamo_sk}" if dynamo_sk})"
|
|
125
|
+
puts "\n Run `belt deploy` to apply. Adding a GSI to an existing table takes ~5-10 min."
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def remove
|
|
129
|
+
validate_tf_exists!
|
|
130
|
+
|
|
131
|
+
content = File.read(DYNAMODB_TF)
|
|
132
|
+
|
|
133
|
+
unless content.include?("name = \"#{@index_name}\"")
|
|
134
|
+
abort "Error: Index '#{@index_name}' not found in #{DYNAMODB_TF}."
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Remove the GSI block
|
|
138
|
+
content.sub!(/\n\s*global_secondary_index \{\n\s*name\s*=\s*"#{Regexp.escape(@index_name)}".*?\n\s*\}/m, '')
|
|
139
|
+
|
|
140
|
+
File.write(DYNAMODB_TF, content)
|
|
141
|
+
puts " remove #{@index_name} from #{@table}"
|
|
142
|
+
puts "\n Run `belt deploy` to apply."
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
private
|
|
146
|
+
|
|
147
|
+
def validate_tf_exists!
|
|
148
|
+
return if File.exist?(DYNAMODB_TF)
|
|
149
|
+
|
|
150
|
+
abort "Error: #{DYNAMODB_TF} not found.\nRun `belt setup tables` first."
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def build_gsi_block(dynamo_pk, dynamo_sk)
|
|
154
|
+
lines = []
|
|
155
|
+
lines << ' global_secondary_index {'
|
|
156
|
+
lines << " name = \"#{@index_name}\""
|
|
157
|
+
lines << " hash_key = \"#{dynamo_pk}\""
|
|
158
|
+
lines << " range_key = \"#{dynamo_sk}\"" if dynamo_sk
|
|
159
|
+
lines << ' projection_type = "ALL"'
|
|
160
|
+
lines << ' }'
|
|
161
|
+
lines.join("\n")
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def build_attribute_blocks(content, dynamo_pk, dynamo_sk)
|
|
165
|
+
blocks = []
|
|
166
|
+
[dynamo_pk, dynamo_sk].compact.each do |key|
|
|
167
|
+
next if content.include?("name = \"#{key}\"")
|
|
168
|
+
|
|
169
|
+
blocks << "\n attribute {\n name = \"#{key}\"\n type = \"S\"\n }"
|
|
170
|
+
end
|
|
171
|
+
blocks.join
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def insert_gsi(content, gsi_block, attr_blocks)
|
|
175
|
+
# Find the table's resource block and insert before point_in_time_recovery
|
|
176
|
+
table_pattern = /resource "aws_dynamodb_table" "#{Regexp.escape(@table)}" \{.*?point_in_time_recovery/m
|
|
177
|
+
|
|
178
|
+
content.sub!(table_pattern) do |match|
|
|
179
|
+
# Insert attributes after last existing attribute block
|
|
180
|
+
unless attr_blocks.empty?
|
|
181
|
+
match.sub!(/( attribute \{.*?\n \})(?!.*attribute)/m) { |attr_match| "#{attr_match}#{attr_blocks}" }
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Insert GSI before point_in_time_recovery
|
|
185
|
+
match.sub('point_in_time_recovery', "#{gsi_block}\n\n point_in_time_recovery")
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
File.write(DYNAMODB_TF, content)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
@@ -34,7 +34,7 @@ module Belt
|
|
|
34
34
|
SUPPORTED_KEYS = %w[
|
|
35
35
|
timeout memory_size env_vars env_keys
|
|
36
36
|
s3_buckets dynamodb_tables sns_triggers sqs_triggers
|
|
37
|
-
reserved_concurrency ephemeral_storage
|
|
37
|
+
reserved_concurrency ephemeral_storage iam_policy_arns
|
|
38
38
|
].freeze
|
|
39
39
|
|
|
40
40
|
def self.run(args)
|
|
@@ -112,6 +112,7 @@ module Belt
|
|
|
112
112
|
sqs_triggers SQS queue triggers
|
|
113
113
|
reserved_concurrency Reserved concurrency limit
|
|
114
114
|
ephemeral_storage Ephemeral storage in MB (512-10240)
|
|
115
|
+
iam_policy_arns Additional IAM policy ARNs (supports ref())
|
|
115
116
|
HELP
|
|
116
117
|
end
|
|
117
118
|
|
|
@@ -14,9 +14,10 @@ module Belt
|
|
|
14
14
|
non_param = segments.reject { |s| s.start_with?(':', '{') }
|
|
15
15
|
return gateway.name if non_param.empty?
|
|
16
16
|
|
|
17
|
-
# Nested resources (/posts/{id}/comments)
|
|
18
|
-
#
|
|
19
|
-
|
|
17
|
+
# Nested resources (/posts/{id}/comments): use the last non-param segment
|
|
18
|
+
# as the controller name (matches Rails — nesting affects URL, not controller lookup).
|
|
19
|
+
# Scoped resources (/admin/users) still use the full path when controller is explicitly set.
|
|
20
|
+
return non_param.last.gsub('-', '_') if route.resource? && non_param.length > 1
|
|
20
21
|
|
|
21
22
|
# For non-resource routes with a single segment (e.g., post '/signup' in :onboarding),
|
|
22
23
|
# the segment is the action name, not the controller. Use the gateway name as controller.
|
|
@@ -250,18 +250,8 @@ module Belt
|
|
|
250
250
|
def output_concise(routes)
|
|
251
251
|
return puts('No routes defined.') if routes.empty?
|
|
252
252
|
|
|
253
|
-
multi_gateway = routes.map { |r| r[:gateway] }.uniq.length > 1
|
|
254
253
|
verb_w = [routes.map { |r| r[:verb].length }.max, 6].max
|
|
255
254
|
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
255
|
gw_w = [routes.map { |r| r[:gateway].to_s.length }.max, 7].max
|
|
266
256
|
lam_w = [routes.map { |r| r[:lambda].length }.max, 6].max
|
|
267
257
|
|
|
@@ -278,15 +268,6 @@ module Belt
|
|
|
278
268
|
end
|
|
279
269
|
end
|
|
280
270
|
|
|
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
271
|
def route_specificity(path, verb)
|
|
291
272
|
segments = path.split('/').reject(&:empty?)
|
|
292
273
|
param_count = segments.count { |s| s.start_with?('{') }
|
|
@@ -75,10 +75,11 @@ module Belt
|
|
|
75
75
|
end
|
|
76
76
|
end
|
|
77
77
|
|
|
78
|
-
def initialize(name, fields, force: false)
|
|
78
|
+
def initialize(name, fields, force: false, quiet: false)
|
|
79
79
|
@name = name.downcase.gsub(/[^a-z0-9_]/, '_')
|
|
80
80
|
@fields = fields
|
|
81
81
|
@force = force
|
|
82
|
+
@quiet = quiet
|
|
82
83
|
@overwrite_all = false
|
|
83
84
|
@singular_name = Belt::Inflector.singularize(@name)
|
|
84
85
|
@resource_name = Belt::Inflector.pluralize(@singular_name)
|
|
@@ -103,14 +104,7 @@ module Belt
|
|
|
103
104
|
|
|
104
105
|
inject_routes
|
|
105
106
|
|
|
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)'
|
|
107
|
+
puts "\n✓ Views for '#{@singular_name}' generated!" unless @quiet
|
|
114
108
|
end
|
|
115
109
|
|
|
116
110
|
private
|
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'
|
data/lib/belt/inflector.rb
CHANGED
|
@@ -42,6 +42,14 @@ module Belt
|
|
|
42
42
|
word.to_s.split('_').map(&:capitalize).join
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
+
# "conversation_id" → "conversationId"
|
|
46
|
+
def camelize_lower(word)
|
|
47
|
+
parts = word.to_s.split('_')
|
|
48
|
+
return word if parts.empty?
|
|
49
|
+
|
|
50
|
+
parts.first + parts[1..].map(&:capitalize).join
|
|
51
|
+
end
|
|
52
|
+
|
|
45
53
|
# "BlogPost" → "blog_post"
|
|
46
54
|
def underscore(word)
|
|
47
55
|
word.to_s
|
data/lib/belt/route_dsl.rb
CHANGED
|
@@ -65,8 +65,9 @@ module Belt
|
|
|
65
65
|
resource_name = name.to_s
|
|
66
66
|
singular = @gateway.send(:singularize, resource_name)
|
|
67
67
|
param_name = options[:param] || "#{singular}_id"
|
|
68
|
+
# Auto-add this resource's table before merging inherited tables
|
|
69
|
+
options = options.merge(tables: [resource_name.to_sym]) unless options.key?(:tables)
|
|
68
70
|
options = merge_inherited_options(options)
|
|
69
|
-
options = @gateway.send(:auto_infer_tables, resource_name, options)
|
|
70
71
|
resource_options = options.merge(route_type: :resources)
|
|
71
72
|
actions = @gateway.send(:determine_actions, options)
|
|
72
73
|
|
data/lib/belt/version.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useState } from 'react'
|
|
2
|
-
import { signIn, completeNewPassword } from '
|
|
2
|
+
import { signIn, completeNewPassword } from '../../lib/auth'
|
|
3
|
+
import './auth.css'
|
|
3
4
|
|
|
4
5
|
export default function Login({ onLogin }) {
|
|
5
6
|
const [email, setEmail] = useState('')
|
|
@@ -72,9 +73,6 @@ export default function Login({ onLogin }) {
|
|
|
72
73
|
<button type="submit" disabled={loading}>
|
|
73
74
|
{loading ? 'Signing in...' : 'Sign In'}
|
|
74
75
|
</button>
|
|
75
|
-
<p className="auth-link">
|
|
76
|
-
Don't have an account? <a href="/signup">Sign up</a>
|
|
77
|
-
</p>
|
|
78
76
|
</form>
|
|
79
77
|
</div>
|
|
80
78
|
)
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
.auth-page {
|
|
2
|
+
--auth-bg: #f6f8fa;
|
|
3
|
+
--auth-card-bg: #ffffff;
|
|
4
|
+
--auth-border: #d0d7de;
|
|
5
|
+
--auth-text: #1f2328;
|
|
6
|
+
--auth-muted: #656d76;
|
|
7
|
+
--auth-input-bg: #f6f8fa;
|
|
8
|
+
--auth-btn: #2da44e;
|
|
9
|
+
--auth-btn-hover: #2c974b;
|
|
10
|
+
--auth-btn-disabled-bg: #94d3a2;
|
|
11
|
+
--auth-btn-disabled-text: #fff;
|
|
12
|
+
--auth-error-bg: rgba(248, 81, 73, 0.08);
|
|
13
|
+
--auth-error-border: rgba(248, 81, 73, 0.4);
|
|
14
|
+
--auth-error-text: #cf222e;
|
|
15
|
+
--auth-link: #0969da;
|
|
16
|
+
--auth-focus: #0969da;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
@media (prefers-color-scheme: dark) {
|
|
20
|
+
.auth-page {
|
|
21
|
+
--auth-bg: #0d1117;
|
|
22
|
+
--auth-card-bg: #161b22;
|
|
23
|
+
--auth-border: #30363d;
|
|
24
|
+
--auth-text: #e6edf3;
|
|
25
|
+
--auth-muted: #8b949e;
|
|
26
|
+
--auth-input-bg: #0d1117;
|
|
27
|
+
--auth-btn: #238636;
|
|
28
|
+
--auth-btn-hover: #2ea043;
|
|
29
|
+
--auth-btn-disabled-bg: #21262d;
|
|
30
|
+
--auth-btn-disabled-text: #484f58;
|
|
31
|
+
--auth-error-bg: rgba(248, 81, 73, 0.1);
|
|
32
|
+
--auth-error-border: rgba(248, 81, 73, 0.4);
|
|
33
|
+
--auth-error-text: #f85149;
|
|
34
|
+
--auth-link: #58a6ff;
|
|
35
|
+
--auth-focus: #58a6ff;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.auth-page {
|
|
40
|
+
min-height: 100vh;
|
|
41
|
+
display: flex;
|
|
42
|
+
align-items: center;
|
|
43
|
+
justify-content: center;
|
|
44
|
+
background: var(--auth-bg);
|
|
45
|
+
padding: 1rem;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.auth-form {
|
|
49
|
+
background: var(--auth-card-bg);
|
|
50
|
+
border: 1px solid var(--auth-border);
|
|
51
|
+
border-radius: 12px;
|
|
52
|
+
padding: 2.5rem;
|
|
53
|
+
width: 100%;
|
|
54
|
+
max-width: 380px;
|
|
55
|
+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.auth-form h2 {
|
|
59
|
+
color: var(--auth-text);
|
|
60
|
+
font-size: 1.5rem;
|
|
61
|
+
margin: 0 0 0.5rem;
|
|
62
|
+
text-align: center;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.auth-form p {
|
|
66
|
+
color: var(--auth-muted);
|
|
67
|
+
font-size: 0.85rem;
|
|
68
|
+
text-align: center;
|
|
69
|
+
margin: 0 0 1.5rem;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.auth-form input {
|
|
73
|
+
display: block;
|
|
74
|
+
width: 100%;
|
|
75
|
+
padding: 0.75rem 1rem;
|
|
76
|
+
margin-bottom: 0.75rem;
|
|
77
|
+
background: var(--auth-input-bg);
|
|
78
|
+
border: 1px solid var(--auth-border);
|
|
79
|
+
border-radius: 8px;
|
|
80
|
+
color: var(--auth-text);
|
|
81
|
+
font-size: 0.9rem;
|
|
82
|
+
outline: none;
|
|
83
|
+
transition: border-color 0.2s;
|
|
84
|
+
box-sizing: border-box;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.auth-form input:focus {
|
|
88
|
+
border-color: var(--auth-focus);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.auth-form input::placeholder {
|
|
92
|
+
color: var(--auth-muted);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.auth-form button {
|
|
96
|
+
display: block;
|
|
97
|
+
width: 100%;
|
|
98
|
+
padding: 0.75rem;
|
|
99
|
+
margin-top: 0.5rem;
|
|
100
|
+
background: var(--auth-btn);
|
|
101
|
+
border: none;
|
|
102
|
+
border-radius: 8px;
|
|
103
|
+
color: #fff;
|
|
104
|
+
font-size: 0.9rem;
|
|
105
|
+
font-weight: 600;
|
|
106
|
+
cursor: pointer;
|
|
107
|
+
transition: background 0.2s;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.auth-form button:hover {
|
|
111
|
+
background: var(--auth-btn-hover);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.auth-form button:disabled {
|
|
115
|
+
background: var(--auth-btn-disabled-bg);
|
|
116
|
+
color: var(--auth-btn-disabled-text);
|
|
117
|
+
cursor: not-allowed;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.auth-error {
|
|
121
|
+
background: var(--auth-error-bg);
|
|
122
|
+
border: 1px solid var(--auth-error-border);
|
|
123
|
+
border-radius: 8px;
|
|
124
|
+
color: var(--auth-error-text);
|
|
125
|
+
padding: 0.6rem 0.75rem;
|
|
126
|
+
font-size: 0.8rem;
|
|
127
|
+
margin-bottom: 1rem;
|
|
128
|
+
text-align: center;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.auth-link {
|
|
132
|
+
color: var(--auth-muted);
|
|
133
|
+
font-size: 0.8rem;
|
|
134
|
+
text-align: center;
|
|
135
|
+
margin-top: 1.25rem !important;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.auth-link a {
|
|
139
|
+
color: var(--auth-link);
|
|
140
|
+
text-decoration: none;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.auth-link a:hover {
|
|
144
|
+
text-decoration: underline;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.auth-btn {
|
|
148
|
+
display: inline-block;
|
|
149
|
+
padding: 0.6rem 1.5rem;
|
|
150
|
+
background: var(--auth-btn);
|
|
151
|
+
border-radius: 8px;
|
|
152
|
+
color: #fff;
|
|
153
|
+
text-decoration: none;
|
|
154
|
+
font-weight: 600;
|
|
155
|
+
font-size: 0.85rem;
|
|
156
|
+
margin-top: 1rem;
|
|
157
|
+
}
|
|
@@ -4,13 +4,11 @@ class <%= @class_name %> < ApplicationRecord
|
|
|
4
4
|
<% @references.each do |ref| -%>
|
|
5
5
|
belongs_to :<%= ref[:referenced_model] %>
|
|
6
6
|
<% end -%>
|
|
7
|
-
<% if @references.any? &&
|
|
7
|
+
<% if @references.any? && @regular_fields.any? -%>
|
|
8
8
|
|
|
9
9
|
<% end -%>
|
|
10
|
-
<% @
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
<% @regular_fields.each do |field| -%>
|
|
14
|
-
attr_accessor :<%= field[:name] %>
|
|
10
|
+
<% all_accessors = @regular_fields.map { |f| ":#{f[:name]}" } -%>
|
|
11
|
+
<% if all_accessors.any? -%>
|
|
12
|
+
attr_accessor <%= all_accessors.join(', ') %>
|
|
15
13
|
<% end -%>
|
|
16
14
|
end
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
# timeout Lambda timeout in seconds (default: 30)
|
|
6
6
|
# memory_size Lambda memory in MB (default: 256)
|
|
7
7
|
# env_vars Environment variables (static values or ref() for Terraform values)
|
|
8
|
+
# iam_policy_arns Additional IAM policy ARNs to attach (supports ref())
|
|
8
9
|
# dynamodb_tables DynamoDB table access (by name — ARNs built by convention)
|
|
9
10
|
# s3_buckets S3 bucket access declarations
|
|
10
11
|
# sns_triggers SNS topic triggers
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: belt
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.20
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Stowzilla
|
|
@@ -43,6 +43,20 @@ dependencies:
|
|
|
43
43
|
- - ">="
|
|
44
44
|
- !ruby/object:Gem::Version
|
|
45
45
|
version: '7.0'
|
|
46
|
+
- !ruby/object:Gem::Dependency
|
|
47
|
+
name: irb
|
|
48
|
+
requirement: !ruby/object:Gem::Requirement
|
|
49
|
+
requirements:
|
|
50
|
+
- - ">="
|
|
51
|
+
- !ruby/object:Gem::Version
|
|
52
|
+
version: '1.0'
|
|
53
|
+
type: :runtime
|
|
54
|
+
prerelease: false
|
|
55
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
56
|
+
requirements:
|
|
57
|
+
- - ">="
|
|
58
|
+
- !ruby/object:Gem::Version
|
|
59
|
+
version: '1.0'
|
|
46
60
|
- !ruby/object:Gem::Dependency
|
|
47
61
|
name: lambda_loadout
|
|
48
62
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -57,6 +71,20 @@ dependencies:
|
|
|
57
71
|
- - "~>"
|
|
58
72
|
- !ruby/object:Gem::Version
|
|
59
73
|
version: '0.0'
|
|
74
|
+
- !ruby/object:Gem::Dependency
|
|
75
|
+
name: rdoc
|
|
76
|
+
requirement: !ruby/object:Gem::Requirement
|
|
77
|
+
requirements:
|
|
78
|
+
- - ">="
|
|
79
|
+
- !ruby/object:Gem::Version
|
|
80
|
+
version: '6.0'
|
|
81
|
+
type: :runtime
|
|
82
|
+
prerelease: false
|
|
83
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
84
|
+
requirements:
|
|
85
|
+
- - ">="
|
|
86
|
+
- !ruby/object:Gem::Version
|
|
87
|
+
version: '6.0'
|
|
60
88
|
description: Belt provides a collection of lightweight utilities for Ruby applications.
|
|
61
89
|
email:
|
|
62
90
|
- andy@stowzilla.com
|
|
@@ -95,6 +123,7 @@ files:
|
|
|
95
123
|
- lib/belt/cli/frontend_setup_command.rb
|
|
96
124
|
- lib/belt/cli/generate_command.rb
|
|
97
125
|
- lib/belt/cli/generator_registry.rb
|
|
126
|
+
- lib/belt/cli/index_command.rb
|
|
98
127
|
- lib/belt/cli/lambda_config_command.rb
|
|
99
128
|
- lib/belt/cli/logs_command.rb
|
|
100
129
|
- lib/belt/cli/new_command.rb
|
|
@@ -149,6 +178,7 @@ files:
|
|
|
149
178
|
- lib/templates/generate/auth/frontend/ProtectedRoute.jsx
|
|
150
179
|
- lib/templates/generate/auth/frontend/SignUp.jsx
|
|
151
180
|
- lib/templates/generate/auth/frontend/apiClient.js
|
|
181
|
+
- lib/templates/generate/auth/frontend/auth.css
|
|
152
182
|
- lib/templates/generate/auth/frontend/auth.js
|
|
153
183
|
- lib/templates/generate/controller.rb.erb
|
|
154
184
|
- lib/templates/generate/model.rb.erb
|