rack_jwt_aegis 0.0.0 → 1.0.0

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.
data/README.md CHANGED
@@ -1,15 +1,19 @@
1
1
  # Rack JWT Aegis
2
2
 
3
- JWT authentication middleware for hierarchical multi-tenant Rack applications with 2-level tenant support.
3
+ [![Gem Version](https://badge.fury.io/rb/rack_jwt_aegis.svg)](https://badge.fury.io/rb/rack_jwt_aegis)
4
+ [![CI](https://github.com/kanutocd/rack_jwt_aegis/workflows/CI/badge.svg)](https://github.com/kanutocd/rack_jwt_aegis/actions)
5
+ [![Coverage Status](https://codecov.io/gh/kanutocd/rack_jwt_aegis/branch/main/graph/badge.svg)](https://codecov.io/gh/kanutocd/rack_jwt_aegis)
6
+ [![Ruby Version](https://img.shields.io/badge/ruby-%3E%3D%203.1.0-ruby.svg)](https://www.ruby-lang.org/en/)
7
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
4
8
 
5
- **Note: This is version 0.0.0 - a placeholder release to reserve the gem name. Implementation is in progress.**
9
+ JWT authentication middleware for hierarchical multi-tenant Rack applications with 2-level tenant support.
6
10
 
7
11
  ## Features
8
12
 
9
13
  - JWT token validation with configurable algorithms
10
- - 2-level multi-tenant support (Company-Group → Company, Organization → Department, etc.)
14
+ - 2-level multi-tenant support (Example: Company-Group → Company, Organization → Department, etc.)
11
15
  - Subdomain-based tenant isolation for top-level tenants
12
- - Company slug access control for sub-level tenants
16
+ - URL pathname slug access control for sub-level tenants
13
17
  - Configurable path exclusions for public endpoints
14
18
  - Custom payload validation
15
19
  - Debug mode for development
@@ -18,61 +22,95 @@ JWT authentication middleware for hierarchical multi-tenant Rack applications wi
18
22
 
19
23
  Add this line to your application's Gemfile:
20
24
 
21
- ```ruby
22
- gem 'rack_jwt_aegis'
25
+ ```bash
26
+ gem 'rack_jwt_aegis'
23
27
  ```
24
28
 
25
29
  And then execute:
26
30
 
27
31
  ```bash
28
- bundle install
32
+ bundle install
29
33
  ```
30
34
 
31
35
  Or install it yourself as:
32
36
 
33
37
  ```bash
34
- gem install rack_jwt_aegis
38
+ gem install rack_jwt_aegis
39
+ ```
40
+
41
+ ## CLI Tool
42
+
43
+ Rack JWT Aegis includes a command-line tool for generating secure JWT secrets:
44
+
45
+ ```bash
46
+ # Generate a secure JWT secret
47
+ rack-jwt-aegis secret
48
+
49
+ # Generate base64-encoded secret
50
+ rack-jwt-aegis secret --format base64
51
+
52
+ # Generate secret in environment variable format
53
+ rack-jwt-aegis secret --env
54
+
55
+ # Generate multiple secrets
56
+ rack-jwt-aegis secret --count 3
57
+
58
+ # Quiet mode (secret only)
59
+ rack-jwt-aegis secret --quiet
60
+
61
+ # Custom length (32 bytes)
62
+ rack-jwt-aegis secret --length 32
63
+
64
+ # Show help
65
+ rack-jwt-aegis --help
35
66
  ```
36
67
 
68
+ ### Security Features
69
+
70
+ - Uses `SecureRandom` for cryptographically secure generation
71
+ - Default 64-byte secrets provide ~512 bits of entropy
72
+ - Multiple output formats: hex, base64, raw
73
+ - Environment variable formatting for easy setup
74
+
37
75
  ## Quick Start
38
76
 
39
77
  ### Rails Application
40
78
 
41
79
  ```ruby
42
- # config/application.rb
43
- config.middleware.insert_before 0, RackJwtAegis::Middleware, {
44
- jwt_secret: ENV['JWT_SECRET'],
45
- company_header_name: 'X-Company-Group-Id',
46
- skip_paths: ['/api/v1/login', '/api/v1/refresh', '/health']
47
- }
80
+ # config/application.rb
81
+ config.middleware.insert_before 0, RackJwtAegis::Middleware, {
82
+ jwt_secret: ENV['JWT_SECRET'],
83
+ tenant_id_header_name: 'X-Tenant-Id',
84
+ skip_paths: ['/api/v1/login', '/health']
85
+ }
48
86
  ```
49
87
 
50
88
  ### Sinatra Application
51
89
 
52
90
  ```ruby
53
- require 'rack_jwt_aegis'
91
+ require 'rack_jwt_aegis'
54
92
 
55
- use RackJwtAegis::Middleware, {
56
- jwt_secret: ENV['JWT_SECRET'],
57
- company_header_name: 'X-Company-Group-Id',
58
- skip_paths: ['/login', '/health']
59
- }
93
+ use RackJwtAegis::Middleware, {
94
+ jwt_secret: ENV['JWT_SECRET'],
95
+ tenant_id_header_name: 'X-Tenant-Id',
96
+ skip_paths: ['/login', '/health']
97
+ }
60
98
  ```
61
99
 
62
100
  ### Pure Rack Application
63
101
 
64
102
  ```ruby
65
- require 'rack_jwt_aegis'
103
+ require 'rack_jwt_aegis'
66
104
 
67
- app = Rack::Builder.new do
68
- use RackJwtAegis::Middleware, {
69
- jwt_secret: ENV['JWT_SECRET'],
70
- validate_subdomain: true,
71
- validate_company_slug: true
72
- }
105
+ app = Rack::Builder.new do
106
+ use RackJwtAegis::Middleware, {
107
+ jwt_secret: ENV['JWT_SECRET'],
108
+ validate_subdomain: true,
109
+ validate_pathname_slug: true
110
+ }
73
111
 
74
- run YourApp.new
75
- end
112
+ run YourApp.new
113
+ end
76
114
  ```
77
115
 
78
116
  ## Configuration Options
@@ -86,13 +124,30 @@ RackJwtAegis::Middleware.new(app, {
86
124
  jwt_algorithm: 'HS256', # Default: 'HS256'
87
125
 
88
126
  # Multi-Tenant Settings
89
- company_header_name: 'X-Company-Group-Id', # Default: 'X-Company-Group-Id'
127
+ tenant_id_header_name: 'X-Tenant-Id', # Default: 'X-Tenant-Id'
90
128
  validate_subdomain: true, # Default: false
91
- validate_company_slug: true, # Default: false
129
+ validate_pathname_slug: true, # Default: false
92
130
 
93
131
  # Path Configuration
94
- skip_paths: ['/health', '/api/v1/login', '/api/v1/refresh'],
95
- company_slug_pattern: /^\/api\/v1\/([^\/]+)\//, # Default pattern
132
+ skip_paths: ['/health', '/api/v1/login'],
133
+ pathname_slug_pattern: /^\/api\/v1\/([^\/]+)\//, # Default pattern
134
+
135
+ # RBAC Configuration
136
+ rbac_enabled: true, # Default: false
137
+ rbac_cache_store: :redis, # Required when RBAC enabled
138
+ rbac_cache_options: { url: ENV['REDIS_URL'] },
139
+ user_permissions_ttl: 3600, # Default: 1800 (30 minutes) - TTL for cached user permissions
140
+
141
+ # Cache Store Configuration (choose one approach)
142
+ # Option 1: Shared cache for both RBAC and permissions
143
+ cache_store: :memory, # :memory, :redis, :memcached, :solid_cache
144
+ cache_options: { url: ENV['REDIS_URL'] },
145
+
146
+ # Option 2: Separate cache stores for RBAC and permissions
147
+ rbac_cache_store: :redis, # For RBAC permissions data
148
+ rbac_cache_options: { url: ENV['REDIS_URL'] },
149
+ permission_cache_store: :memory, # For cached user permissions
150
+ permission_cache_options: {},
96
151
 
97
152
  # Response Customization
98
153
  unauthorized_response: { error: 'Authentication required' },
@@ -118,9 +173,9 @@ RackJwtAegis::Middleware.new(app, {
118
173
  # Flexible Payload Mapping
119
174
  payload_mapping: {
120
175
  user_id: :sub, # Map 'sub' claim to user_id
121
- company_group_id: :company_id, # Map 'company_id' claim
122
- company_group_domain: :domain, # Map 'domain' claim
123
- company_slugs: :accessible_companies # Map array of accessible companies
176
+ tenant_id: :company_group_id, # Map 'company_group_id' claim
177
+ subdomain: :company_group_domain_name, # Map 'company_group_domain_name' claim
178
+ pathname_slugs: :accessible_company_slugs # Map array of accessible companies
124
179
  },
125
180
 
126
181
  # Custom Tenant Extraction
@@ -147,17 +202,79 @@ config.validate_subdomain = true
147
202
 
148
203
  ```ruby
149
204
  # Validates that the requested company slug is accessible to the user
150
- config.validate_company_slug = true
151
- config.company_slug_pattern = /^\/api\/v1\/([^\/]+)\//
205
+ config.validate_pathname_slug = true
206
+ config.pathname_slug_pattern = /^\/api\/v1\/([^\/]+)\//
152
207
  ```
153
208
 
154
209
  ### Header-Based Validation
155
210
 
156
211
  ```ruby
157
- # Validates the X-Company-Group-Id header against JWT payload
158
- config.company_header_name = 'X-Company-Group-Id'
212
+ # Validates the X-Tenant-Id header against JWT payload
213
+ config.tenant_id_header_name = 'X-Tenant-Id'
214
+ ```
215
+
216
+ The value from this request header entry will be used to verify the JWT's mapped `tenant_id` claim.
217
+
218
+ ## Request Context Access
219
+
220
+ After successful JWT authentication, the middleware stores user context in the Rack environment for easy access in your application:
221
+
222
+ ### Basic Usage
223
+
224
+ ```ruby
225
+ # In your controllers or middleware
226
+ class UsersController < ApplicationController
227
+ def index
228
+ # Check if request is authenticated
229
+ return unauthorized unless RackJwtAegis::RequestContext.authenticated?(request.env)
230
+
231
+ # Get user information
232
+ user_id = RackJwtAegis::RequestContext.user_id(request.env)
233
+ tenant_id = RackJwtAegis::RequestContext.tenant_id(request.env)
234
+
235
+ # Access full JWT payload
236
+ payload = RackJwtAegis::RequestContext.payload(request.env)
237
+ roles = payload['roles']
238
+
239
+ # Your business logic here
240
+ users = User.where(tenant_id: tenant_id)
241
+ render json: users
242
+ end
243
+ end
244
+ ```
245
+
246
+ ### Multi-Tenant Context
247
+
248
+ ```ruby
249
+ # Access subdomain information
250
+ subdomain = RackJwtAegis::RequestContext.subdomain(request.env)
251
+ # => "acme-group-of-companies"
252
+
253
+ # Check pathname slug access
254
+ accessible_companies = RackJwtAegis::RequestContext.pathname_slugs(request.env)
255
+ # => ["company-a", "company-b"]
256
+
257
+ # Check if user has access to specific company
258
+ has_access = RackJwtAegis::RequestContext.has_company_access?(request.env, "company-a")
259
+ # => true
260
+
261
+ # Helper methods for request objects
262
+ user_id = RackJwtAegis::RequestContext.current_user_id(request)
263
+ tenant_id = RackJwtAegis::RequestContext.current_tenant_id(request)
159
264
  ```
160
265
 
266
+ ### Available Context Methods
267
+
268
+ - `authenticated?(env)` - Check if request is authenticated
269
+ - `payload(env)` - Get full JWT payload hash
270
+ - `user_id(env)` - Get authenticated user ID
271
+ - `tenant_id(env)` - Get tenant/company group ID
272
+ - `subdomain(env)` - Get subdomain from JWT
273
+ - `pathname_slugs(env)` - Get array of accessible company slugs
274
+ - `current_user_id(request)` - Helper for request objects
275
+ - `current_tenant_id(request)` - Helper for request objects
276
+ - `has_company_access?(env, slug)` - Check company access
277
+
161
278
  ## JWT Payload Structure
162
279
 
163
280
  The middleware expects JWT payloads with the following structure:
@@ -165,9 +282,9 @@ The middleware expects JWT payloads with the following structure:
165
282
  ```json
166
283
  {
167
284
  "user_id": 12345,
168
- "company_group_id": 67890,
169
- "company_group_domain": "acme.example.com",
170
- "company_slugs": ["acme", "acme-corp"],
285
+ "tenant_id": 67890,
286
+ "subdomain": "acme-group-of-companies", # the subdomain part of the host of the request url, e.g. `http://acme-group-of-companies.example.com`
287
+ "pathname_slugs": ["an-acme-company-subsidiary", "another-acme-company-the-user-has-access"], # the user has access to these kind of request urls: https://acme-group-of-companies.example.com/api/v1/an-acme-company-subsidiary/* or https://acme-group-of-companies.example.com/api/v1/another-acme-company-the-user-has-access/
171
288
  "roles": ["admin", "user"],
172
289
  "exp": 1640995200,
173
290
  "iat": 1640991600
@@ -184,10 +301,164 @@ You can customize the payload mapping using the `payload_mapping` configuration
184
301
  - Subdomain validation
185
302
  - Request path filtering for public endpoints
186
303
 
187
- ## Performance
304
+ ## Performance & Caching
188
305
 
189
306
  - Skip paths are checked before JWT processing
190
307
  - Low memory footprint
308
+ - Multi-tier permission caching system for RBAC performance
309
+ - TTL-based cache invalidation for user permissions
310
+ - Support for multiple cache stores: `:memory`, `:redis`, `:memcached`, `:solid_cache`
311
+
312
+ ### Cache Store Configuration
313
+
314
+ #### Supported Cache Stores
315
+
316
+ 1. **Memory Cache** (`:memory`) - For development and testing
317
+ 2. **Redis Cache** (`:redis`) - For production with high availability
318
+ 3. **Memcached Cache** (`:memcached`) - For distributed caching
319
+ 4. **Solid Cache** (`:solid_cache`) - For Rails 8+ applications
320
+
321
+ #### Configuration Examples
322
+
323
+ ```ruby
324
+ # Memory cache (development/testing)
325
+ config.cache_store = :memory
326
+
327
+ # Redis cache
328
+ config.cache_store = :redis
329
+ config.cache_options = { url: ENV['REDIS_URL'] }
330
+
331
+ # Memcached cache
332
+ config.cache_store = :memcached
333
+ config.cache_options = { servers: ['localhost:11211'] }
334
+
335
+ # Solid Cache (Rails 8+)
336
+ config.cache_store = :solid_cache
337
+ ```
338
+
339
+ #### Separate Cache Stores
340
+
341
+ You can configure separate cache stores for RBAC permissions data and cached user permissions:
342
+
343
+ ```ruby
344
+ # Use Redis for RBAC data (shared across instances)
345
+ config.rbac_cache_store = :redis
346
+ config.rbac_cache_options = { url: ENV['REDIS_URL'] }
347
+
348
+ # Use memory for user permission cache (faster local access)
349
+ config.permission_cache_store = :memory
350
+ config.permission_cache_options = {}
351
+ ```
352
+
353
+ ## RBAC Cache Format
354
+
355
+ When RBAC is enabled, the middleware expects permissions to be stored in the cache with this exact format:
356
+
357
+ ```json
358
+ {
359
+ "last_update": 1640995200,
360
+ "permissions": [
361
+ {
362
+ "123": [
363
+ "sales/invoices:get",
364
+ "sales/invoices:post",
365
+ "%r{sales/invoices/\\d+}:get",
366
+ "%r{sales/invoices/\\d+}:put",
367
+ "users/*:get"
368
+ ]
369
+ },
370
+ {
371
+ "456": ["admin/*:*", "reports:get"]
372
+ }
373
+ ]
374
+ }
375
+ ```
376
+
377
+ ### Format Specification
378
+
379
+ - **last_update**: Timestamp for cache invalidation
380
+ - **permissions**: Array of role permission objects
381
+ - **Role ID**: String or numeric identifier for user roles
382
+ - **Permission Format**: `"resource-endpoint:http-method"`
383
+ - **resource-endpoint**: API path (literal string or regex pattern)
384
+ - **http-method**: `get`, `post`, `put`, `delete`, or `*` (wildcard)
385
+
386
+ ### Permission Examples
387
+
388
+ ```ruby
389
+ # Literal path matching
390
+ "sales/invoices:get" # GET /api/v1/company/sales/invoices
391
+ "users/profile:put" # PUT /api/v1/company/users/profile
392
+
393
+ # Regex pattern matching
394
+ "%r{ sales/invoices/\\d+}:get" # GET /api/v1/company/sales/invoices/123
395
+ "%r{ users/\\d+/orders}:*" # Any method on /api/v1/company/users/123/orders
396
+
397
+ # Wildcard method
398
+ "reports:*" # Any method on reports endpoint
399
+ "admin/*:*" # Full admin access
400
+ ```
401
+
402
+ ### Request Authorization Flow
403
+
404
+ 1. **Check User Permissions Cache**: Fast lookup in middleware cache
405
+
406
+ - **RBAC Update Check**: If RBAC permissions updated within TTL → **Nuke entire cache**
407
+ - **TTL Check**: If individual permission older than configured TTL → Remove only that permission
408
+ - If cache valid and permission found: **✅ Authorized**
409
+ - If cache valid but no permission: **❌ 403 Forbidden**
410
+
411
+ 2. **RBAC Permissions Validation**: Full permission evaluation
412
+
413
+ - Extract user roles from JWT payload (`roles`, `role`, `user_roles`, or `role_ids` field)
414
+ - Load RBAC permissions collection and validate format
415
+ - For each user role, check if any permission matches:
416
+ - Extract resource path from request URL (removes subdomain/pathname slug)
417
+ - Match against permission patterns (literal or regex)
418
+ - Validate HTTP method (exact match or wildcard)
419
+ - If authorized: Cache permission for future requests
420
+ - Return 403 Forbidden if no matching permissions found
421
+
422
+ 3. **Cache Storage**: Successful permissions cached with simple key-value format:
423
+ ```json
424
+ {
425
+ "12345:acme-group.localhost.local/api/v1/company/sales/invoices:get": 1640995200,
426
+ "12345:acme-group.localhost.local/api/v1/company/sales/invoices:post": 1640995200
427
+ }
428
+ ```
429
+ TTL configurable via `user_permissions_ttl` option (default: 30 minutes)
430
+
431
+ ### Cache Invalidation Strategy
432
+
433
+ **RBAC Collection Updated** (e.g., role permissions changed):
434
+
435
+ - **Condition**: RBAC `last_update` within configured TTL
436
+ - **Action**: **Nuke entire cache** (all users, all permissions)
437
+ - **Reason**: Any permission could have changed, safer to re-evaluate everything
438
+
439
+ **Individual Permission TTL Expired**:
440
+
441
+ - **Condition**: Specific permission older than configured TTL
442
+ - **Action**: **Remove only that permission** (preserve others)
443
+ - **Reason**: Permission naturally aged out, other permissions still valid
444
+
445
+ ### Example Authorization
446
+
447
+ Request: `POST https://acme-group.localhost/api/v1/an-acme-company/sales/invoices`
448
+
449
+ - User has role: `123`
450
+ - Role `123` permissions: `["sales/invoices:get", "sales/invoices:post"]`
451
+ - Extracted resource path: `sales/invoices`
452
+ - Request method: `POST`
453
+ - Result: **✅ Authorized** (matches `sales/invoices:post`)
454
+
455
+ Request: `DELETE https://acme-group.localhost/api/v1/an-acme-company/sales/invoices/456`
456
+
457
+ - User has role: `123`
458
+ - Role `123` permissions: `["sales/invoices:get", "%r{sales/invoices/\\d+}:put"]`
459
+ - Extracted resource path: `sales/invoices/456`
460
+ - Request method: `DELETE`
461
+ - Result: **❌ 403 Forbidden** (no DELETE permission)
191
462
 
192
463
  ## Error Handling
193
464
 
@@ -196,11 +467,34 @@ The middleware returns appropriate HTTP status codes:
196
467
  - **401 Unauthorized** - Missing, invalid, or expired JWT
197
468
  - **403 Forbidden** - Valid JWT but insufficient permissions/access
198
469
 
470
+ ## Documentation
471
+
472
+ API documentation is available online and is automatically generated from the source code:
473
+
474
+ - **📚 [Online Documentation](https://kanutocd.github.io/rack_jwt_aegis/)** - Auto-deployed from the main branch
475
+ - **🔧 Generate Locally**: `bundle exec yard doc` and open `doc/index.html`
476
+
477
+ ### Documentation Features
478
+
479
+ - Complete API reference for all classes and modules
480
+ - Code examples and usage patterns
481
+ - Architecture decision records (ADRs)
482
+ - Integration examples for common use cases
483
+
199
484
  ## Development
200
485
 
201
486
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
202
487
 
203
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
488
+ To install this gem onto your local machine, run `bundle exec rake install`.
489
+
490
+ ### Test Coverage
491
+
492
+ This project maintains high test coverage:
493
+
494
+ - **Line Coverage**: 97.8% (668/683 lines)
495
+ - **Branch Coverage**: 86.62% (259/299 branches)
496
+
497
+ Run tests with coverage: `bundle exec rake test`
204
498
 
205
499
  ## Contributing
206
500
 
data/Rakefile CHANGED
@@ -9,4 +9,56 @@ Rake::TestTask.new(:test) do |t|
9
9
  t.test_files = FileList['test/**/*_test.rb']
10
10
  end
11
11
 
12
+ # YARD documentation tasks
13
+ begin
14
+ require 'yard'
15
+ require 'yard/rake/yardoc_task'
16
+
17
+ # Load custom GFM configuration if available
18
+ begin
19
+ require_relative '.yard/yard_gfm_config'
20
+ rescue LoadError
21
+ # GFM config not available, use default markdown processing
22
+ end
23
+
24
+ YARD::Rake::YardocTask.new do |t|
25
+ t.files = ['lib/**/*.rb']
26
+ t.options = [
27
+ '--output-dir', 'doc',
28
+ '--readme', 'README.md',
29
+ '--markup-provider', 'kramdown',
30
+ '--markup', 'markdown'
31
+ ]
32
+ t.stats_options = ['--list-undoc']
33
+ end
34
+
35
+ desc 'Generate YARD documentation and open in browser'
36
+ task :docs do
37
+ Rake::Task['yard'].invoke
38
+ case RbConfig::CONFIG['host_os']
39
+ when /mswin|mingw|cygwin/
40
+ system 'start doc/index.html'
41
+ when /darwin/
42
+ system 'open doc/index.html'
43
+ when /linux|bsd/
44
+ system 'xdg-open doc/index.html'
45
+ end
46
+ end
47
+
48
+ desc 'Run YARD documentation server'
49
+ task 'docs:server' do
50
+ puts 'Starting YARD documentation server at http://localhost:8808'
51
+ puts 'Press Ctrl+C to stop the server'
52
+ system 'yard server --reload --port 8808'
53
+ end
54
+
55
+ desc 'Check documentation coverage'
56
+ task 'docs:coverage' do
57
+ puts 'Generating YARD documentation coverage report...'
58
+ system 'yard stats --list-undoc'
59
+ end
60
+ rescue LoadError
61
+ puts 'YARD gem is not available. Install it with: gem install yard'
62
+ end
63
+
12
64
  task default: :test
data/bin/console ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'bundler/setup'
5
+ require 'rack_jwt_aegis'
6
+
7
+ # You can add fixtures and/or initialization code here to make experimenting
8
+ # with your gem easier. You can also use a different console, if you like.
9
+
10
+ require 'irb'
11
+ IRB.start(__FILE__)
data/bin/docs ADDED
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'fileutils'
5
+
6
+ puts '🔧 Generating documentation...'
7
+ system('bundle exec yard doc')
8
+
9
+ puts "\n📊 Documentation coverage:"
10
+ system('bundle exec yard stats --list-undoc')
11
+
12
+ puts "\n✅ Documentation generated successfully!"
13
+ puts '📂 Open doc/index.html in your browser to view the documentation'
14
+
15
+ # Check if we're in a git repository and suggest deployment
16
+ if system('git rev-parse --git-dir > /dev/null 2>&1')
17
+ puts "\n💡 To deploy to GitHub Pages:"
18
+ puts " git add . && git commit -m 'Update documentation' && git push"
19
+ puts ' Or trigger manually via GitHub Actions workflow dispatch'
20
+ end
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here