rhino-rails 0.9.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.
- checksums.yaml +7 -0
- data/README.md +59 -0
- data/lib/rhino/blueprint/blueprint_parser.rb +198 -0
- data/lib/rhino/blueprint/blueprint_validator.rb +209 -0
- data/lib/rhino/blueprint/generators/factory_generator.rb +74 -0
- data/lib/rhino/blueprint/generators/policy_generator.rb +154 -0
- data/lib/rhino/blueprint/generators/seeder_generator.rb +160 -0
- data/lib/rhino/blueprint/generators/test_generator.rb +291 -0
- data/lib/rhino/blueprint/manifest_manager.rb +81 -0
- data/lib/rhino/commands/base_command.rb +57 -0
- data/lib/rhino/commands/blueprint_command.rb +529 -0
- data/lib/rhino/commands/export_postman_command.rb +328 -0
- data/lib/rhino/commands/export_types_command.rb +202 -0
- data/lib/rhino/commands/generate_command.rb +535 -0
- data/lib/rhino/commands/install_command.rb +408 -0
- data/lib/rhino/commands/invitation_link_command.rb +107 -0
- data/lib/rhino/concerns/belongs_to_organization.rb +49 -0
- data/lib/rhino/concerns/has_audit_trail.rb +125 -0
- data/lib/rhino/concerns/has_auto_scope.rb +91 -0
- data/lib/rhino/concerns/has_permissions.rb +117 -0
- data/lib/rhino/concerns/has_rhino.rb +93 -0
- data/lib/rhino/concerns/has_uuid.rb +26 -0
- data/lib/rhino/concerns/has_validation.rb +250 -0
- data/lib/rhino/concerns/hidable_columns.rb +180 -0
- data/lib/rhino/configuration.rb +101 -0
- data/lib/rhino/controllers/auth_controller.rb +242 -0
- data/lib/rhino/controllers/invitations_controller.rb +231 -0
- data/lib/rhino/controllers/resources_controller.rb +813 -0
- data/lib/rhino/engine.rb +64 -0
- data/lib/rhino/mailers/invitation_mailer.rb +22 -0
- data/lib/rhino/middleware/resolve_organization_from_route.rb +72 -0
- data/lib/rhino/models/audit_log.rb +17 -0
- data/lib/rhino/models/organization_invitation.rb +57 -0
- data/lib/rhino/models/rhino_model.rb +387 -0
- data/lib/rhino/policies/invitation_policy.rb +54 -0
- data/lib/rhino/policies/resource_policy.rb +197 -0
- data/lib/rhino/query_builder.rb +278 -0
- data/lib/rhino/railtie.rb +11 -0
- data/lib/rhino/resource_scope.rb +59 -0
- data/lib/rhino/routes.rb +124 -0
- data/lib/rhino/tasks/rhino.rake +47 -0
- data/lib/rhino/templates/audit_trail/create_audit_logs.rb.erb +26 -0
- data/lib/rhino/templates/generate/factory.rb.erb +43 -0
- data/lib/rhino/templates/generate/migration.rb.erb +26 -0
- data/lib/rhino/templates/generate/model.rb.erb +55 -0
- data/lib/rhino/templates/generate/policy.rb.erb +52 -0
- data/lib/rhino/templates/generate/scope.rb.erb +31 -0
- data/lib/rhino/templates/multi_tenant/factories/organizations.rb.erb +9 -0
- data/lib/rhino/templates/multi_tenant/factories/roles.rb.erb +9 -0
- data/lib/rhino/templates/multi_tenant/factories/user_roles.rb.erb +10 -0
- data/lib/rhino/templates/multi_tenant/factories/users.rb.erb +9 -0
- data/lib/rhino/templates/multi_tenant/migrations/create_organizations.rb.erb +15 -0
- data/lib/rhino/templates/multi_tenant/migrations/create_roles.rb.erb +15 -0
- data/lib/rhino/templates/multi_tenant/migrations/create_user_roles.rb.erb +16 -0
- data/lib/rhino/templates/multi_tenant/migrations/create_users.rb.erb +15 -0
- data/lib/rhino/templates/multi_tenant/models/organization.rb.erb +18 -0
- data/lib/rhino/templates/multi_tenant/models/role.rb.erb +11 -0
- data/lib/rhino/templates/multi_tenant/models/user.rb.erb +14 -0
- data/lib/rhino/templates/multi_tenant/models/user_role.rb.erb +9 -0
- data/lib/rhino/templates/multi_tenant/policies/organization_policy.rb.erb +6 -0
- data/lib/rhino/templates/multi_tenant/policies/role_policy.rb.erb +6 -0
- data/lib/rhino/templates/multi_tenant/seeders/organization_seeder.rb.erb +9 -0
- data/lib/rhino/templates/multi_tenant/seeders/role_seeder.rb.erb +19 -0
- data/lib/rhino/templates/rhino.rb +71 -0
- data/lib/rhino/templates/rhino_model.rb +104 -0
- data/lib/rhino/templates/routes.rb +13 -0
- data/lib/rhino/version.rb +5 -0
- data/lib/rhino/views/lumina/invitation_mailer/invite.html.erb +29 -0
- data/lib/rhino-rails.rb +3 -0
- data/lib/rhino.rb +26 -0
- metadata +282 -0
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rhino/commands/base_command"
|
|
4
|
+
|
|
5
|
+
module Rhino
|
|
6
|
+
module Commands
|
|
7
|
+
# Interactive setup wizard — mirrors Laravel `php artisan rhino:install` exactly.
|
|
8
|
+
#
|
|
9
|
+
# Usage: rails rhino:install
|
|
10
|
+
class InstallCommand < BaseCommand
|
|
11
|
+
def perform
|
|
12
|
+
say ""
|
|
13
|
+
say "+ Rhino :: Install :: Let's build something great +", :cyan
|
|
14
|
+
say ""
|
|
15
|
+
|
|
16
|
+
features = multi_select("Which features would you like to configure?") do |menu|
|
|
17
|
+
menu.default 1
|
|
18
|
+
menu.choice "Publish config & routes", "publish"
|
|
19
|
+
menu.choice "Multi-tenant support (Organizations, Roles)", "multi_tenant"
|
|
20
|
+
menu.choice "Audit trail (change logging)", "audit_trail"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
test_framework = select("Which test framework do you use?") do |menu|
|
|
24
|
+
menu.default 1
|
|
25
|
+
menu.choice "RSpec", "rspec"
|
|
26
|
+
menu.choice "Minitest", "minitest"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
identifier_column = "id"
|
|
30
|
+
roles = ["admin"]
|
|
31
|
+
|
|
32
|
+
if features.include?("multi_tenant")
|
|
33
|
+
identifier_column = ask("What column should be used to identify organizations?", default: "id")
|
|
34
|
+
|
|
35
|
+
roles_input = ask("What roles should your app have?", default: "admin, editor, viewer")
|
|
36
|
+
roles = (["admin"] + roles_input.split(",").map(&:strip)).uniq
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
say ""
|
|
40
|
+
|
|
41
|
+
if features.include?("publish")
|
|
42
|
+
task("Publishing config") { publish_config(test_framework) }
|
|
43
|
+
task("Publishing routes") { publish_routes }
|
|
44
|
+
task("Creating blueprint directory") { create_blueprint_directory }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
if features.include?("multi_tenant")
|
|
48
|
+
task("Creating migrations") { create_multi_tenant_migrations }
|
|
49
|
+
task("Creating models") { create_multi_tenant_models(roles) }
|
|
50
|
+
task("Creating factories") { create_factories }
|
|
51
|
+
task("Creating policies") { create_policies }
|
|
52
|
+
task("Updating config") { update_config(identifier_column) }
|
|
53
|
+
task("Creating seeders") { create_seeders(roles) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
if features.include?("audit_trail")
|
|
57
|
+
task("Creating audit trail migration") { create_audit_trail_migration }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
say ""
|
|
61
|
+
run_post_install_steps(features)
|
|
62
|
+
|
|
63
|
+
install_ai_skill
|
|
64
|
+
|
|
65
|
+
say ""
|
|
66
|
+
say "Rhino installed successfully!", :green
|
|
67
|
+
say ""
|
|
68
|
+
|
|
69
|
+
print_next_steps(features)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
# ----------------------------------------------------------------
|
|
75
|
+
# Publish
|
|
76
|
+
# ----------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
def publish_config(test_framework)
|
|
79
|
+
template_path = File.expand_path("../../templates/rhino.rb", __FILE__)
|
|
80
|
+
dest_path = Rails.root.join("config/initializers/rhino.rb")
|
|
81
|
+
|
|
82
|
+
FileUtils.mkdir_p(File.dirname(dest_path))
|
|
83
|
+
|
|
84
|
+
content = File.read(template_path)
|
|
85
|
+
content = content.gsub('test_framework: "rspec"', "test_framework: \"#{test_framework}\"")
|
|
86
|
+
File.write(dest_path, content)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def publish_routes
|
|
90
|
+
template_path = File.expand_path("../../templates/routes.rb", __FILE__)
|
|
91
|
+
dest_path = Rails.root.join("config/routes/rhino.rb")
|
|
92
|
+
|
|
93
|
+
FileUtils.mkdir_p(File.dirname(dest_path))
|
|
94
|
+
FileUtils.cp(template_path, dest_path)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# ----------------------------------------------------------------
|
|
98
|
+
# Multi-tenant
|
|
99
|
+
# ----------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
def create_multi_tenant_migrations
|
|
102
|
+
timestamp = Time.current.strftime("%Y%m%d%H%M%S")
|
|
103
|
+
migrations_path = Rails.root.join("db/migrate")
|
|
104
|
+
FileUtils.mkdir_p(migrations_path)
|
|
105
|
+
|
|
106
|
+
templates_dir = File.expand_path("../../templates/multi_tenant/migrations", __FILE__)
|
|
107
|
+
|
|
108
|
+
{
|
|
109
|
+
"create_users" => "#{timestamp}00",
|
|
110
|
+
"create_organizations" => "#{timestamp}01",
|
|
111
|
+
"create_roles" => "#{timestamp}02",
|
|
112
|
+
"create_user_roles" => "#{timestamp}03"
|
|
113
|
+
}.each do |name, ts|
|
|
114
|
+
template = File.join(templates_dir, "#{name}.rb.erb")
|
|
115
|
+
next unless File.exist?(template)
|
|
116
|
+
|
|
117
|
+
content = ERB.new(File.read(template), trim_mode: "-").result(binding)
|
|
118
|
+
File.write(migrations_path.join("#{ts}_#{name}.rb"), content)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def create_multi_tenant_models(roles)
|
|
123
|
+
models_path = Rails.root.join("app/models")
|
|
124
|
+
FileUtils.mkdir_p(models_path)
|
|
125
|
+
|
|
126
|
+
templates_dir = File.expand_path("../../templates/multi_tenant/models", __FILE__)
|
|
127
|
+
|
|
128
|
+
%w[user organization role user_role].each do |model|
|
|
129
|
+
template = File.join(templates_dir, "#{model}.rb.erb")
|
|
130
|
+
next unless File.exist?(template)
|
|
131
|
+
|
|
132
|
+
content = ERB.new(File.read(template), trim_mode: "-").result_with_hash(roles: roles)
|
|
133
|
+
File.write(models_path.join("#{model}.rb"), content)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def create_factories
|
|
138
|
+
factories_path = Rails.root.join("spec/factories")
|
|
139
|
+
FileUtils.mkdir_p(factories_path)
|
|
140
|
+
|
|
141
|
+
templates_dir = File.expand_path("../../templates/multi_tenant/factories", __FILE__)
|
|
142
|
+
|
|
143
|
+
%w[users organizations roles user_roles].each do |factory|
|
|
144
|
+
template = File.join(templates_dir, "#{factory}.rb.erb")
|
|
145
|
+
next unless File.exist?(template)
|
|
146
|
+
|
|
147
|
+
content = ERB.new(File.read(template), trim_mode: "-").result(binding)
|
|
148
|
+
File.write(factories_path.join("#{factory}.rb"), content)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def create_policies
|
|
153
|
+
policies_path = Rails.root.join("app/policies")
|
|
154
|
+
FileUtils.mkdir_p(policies_path)
|
|
155
|
+
|
|
156
|
+
templates_dir = File.expand_path("../../templates/multi_tenant/policies", __FILE__)
|
|
157
|
+
|
|
158
|
+
%w[organization_policy role_policy].each do |policy|
|
|
159
|
+
template = File.join(templates_dir, "#{policy}.rb.erb")
|
|
160
|
+
next unless File.exist?(template)
|
|
161
|
+
|
|
162
|
+
content = ERB.new(File.read(template), trim_mode: "-").result(binding)
|
|
163
|
+
File.write(policies_path.join("#{policy}.rb"), content)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def update_config(identifier_column)
|
|
168
|
+
config_path = Rails.root.join("config/initializers/rhino.rb")
|
|
169
|
+
return unless File.exist?(config_path)
|
|
170
|
+
|
|
171
|
+
content = File.read(config_path)
|
|
172
|
+
|
|
173
|
+
# Update organization_identifier_column
|
|
174
|
+
content = content.gsub(
|
|
175
|
+
'organization_identifier_column: "id"',
|
|
176
|
+
"organization_identifier_column: \"#{identifier_column}\""
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
# Add organization and role models
|
|
180
|
+
unless content.include?("config.model :organizations")
|
|
181
|
+
content = content.gsub(
|
|
182
|
+
"# config.model :posts, 'Post'",
|
|
183
|
+
"config.model :organizations, 'Organization'\n config.model :roles, 'Role'\n # config.model :posts, 'Post'"
|
|
184
|
+
)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Add tenant route group
|
|
188
|
+
unless content.include?("config.route_group :tenant")
|
|
189
|
+
content = content.gsub(
|
|
190
|
+
"# config.route_group :default",
|
|
191
|
+
"config.route_group :tenant, prefix: \":organization\", middleware: [Rhino::Middleware::ResolveOrganizationFromRoute], models: :all\n # config.route_group :default"
|
|
192
|
+
)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
File.write(config_path, content)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def create_seeders(roles)
|
|
199
|
+
seeders_path = Rails.root.join("db/seeds")
|
|
200
|
+
FileUtils.mkdir_p(seeders_path)
|
|
201
|
+
|
|
202
|
+
templates_dir = File.expand_path("../../templates/multi_tenant/seeders", __FILE__)
|
|
203
|
+
|
|
204
|
+
template = File.join(templates_dir, "role_seeder.rb.erb")
|
|
205
|
+
if File.exist?(template)
|
|
206
|
+
content = ERB.new(File.read(template), trim_mode: "-").result_with_hash(roles: roles)
|
|
207
|
+
File.write(seeders_path.join("role_seeder.rb"), content)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
template = File.join(templates_dir, "organization_seeder.rb.erb")
|
|
211
|
+
if File.exist?(template)
|
|
212
|
+
content = ERB.new(File.read(template), trim_mode: "-").result(binding)
|
|
213
|
+
File.write(seeders_path.join("organization_seeder.rb"), content)
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# ----------------------------------------------------------------
|
|
218
|
+
# Audit trail
|
|
219
|
+
# ----------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
def create_audit_trail_migration
|
|
222
|
+
timestamp = Time.current.strftime("%Y%m%d%H%M%S")
|
|
223
|
+
migrations_path = Rails.root.join("db/migrate")
|
|
224
|
+
FileUtils.mkdir_p(migrations_path)
|
|
225
|
+
|
|
226
|
+
# Check for existing
|
|
227
|
+
existing = Dir.glob(migrations_path.join("*_create_audit_logs.rb"))
|
|
228
|
+
return unless existing.empty?
|
|
229
|
+
|
|
230
|
+
template = File.expand_path("../../templates/audit_trail/create_audit_logs.rb.erb", __FILE__)
|
|
231
|
+
if File.exist?(template)
|
|
232
|
+
content = ERB.new(File.read(template), trim_mode: "-").result(binding)
|
|
233
|
+
File.write(migrations_path.join("#{timestamp}_create_audit_logs.rb"), content)
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# ----------------------------------------------------------------
|
|
238
|
+
# Post-install
|
|
239
|
+
# ----------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
def run_post_install_steps(features)
|
|
242
|
+
has_migrations = features.include?("multi_tenant") || features.include?("audit_trail")
|
|
243
|
+
|
|
244
|
+
if has_migrations
|
|
245
|
+
if yes?("Would you like to run migrations now?")
|
|
246
|
+
task("Running migrations") { system("rails db:migrate") }
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
if features.include?("multi_tenant")
|
|
251
|
+
if yes?("Would you like to seed the database?")
|
|
252
|
+
task("Seeding database") { system("rails db:seed") }
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def print_next_steps(features)
|
|
258
|
+
say "Remaining steps:", :yellow
|
|
259
|
+
say ""
|
|
260
|
+
|
|
261
|
+
step = 1
|
|
262
|
+
|
|
263
|
+
if features.include?("audit_trail")
|
|
264
|
+
say " #{step}. Add Rhino::HasAuditTrail concern to your models:"
|
|
265
|
+
say " include Rhino::HasAuditTrail"
|
|
266
|
+
step += 1
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
if features.include?("multi_tenant")
|
|
270
|
+
say " #{step}. Add Rhino::HasPermissions concern to your User model:"
|
|
271
|
+
say " include Rhino::HasPermissions"
|
|
272
|
+
step += 1
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
say ""
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# ----------------------------------------------------------------
|
|
279
|
+
# Blueprint directory
|
|
280
|
+
# ----------------------------------------------------------------
|
|
281
|
+
|
|
282
|
+
def create_blueprint_directory
|
|
283
|
+
bp_dir = Rails.root.join(".rhino", "blueprints")
|
|
284
|
+
FileUtils.mkdir_p(bp_dir)
|
|
285
|
+
|
|
286
|
+
guide_path = bp_dir.join("..", "BLUEPRINT.md")
|
|
287
|
+
unless File.exist?(guide_path)
|
|
288
|
+
File.write(guide_path, blueprint_guide_content)
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def blueprint_guide_content
|
|
293
|
+
<<~MD
|
|
294
|
+
# Rhino Blueprint — AI Guide
|
|
295
|
+
|
|
296
|
+
Use this file to teach AI assistants how to generate valid YAML blueprint files.
|
|
297
|
+
|
|
298
|
+
## Quick Start
|
|
299
|
+
|
|
300
|
+
1. Create `_roles.yaml` in `.rhino/blueprints/` with your role definitions
|
|
301
|
+
2. Create `{model_slug}.yaml` for each model
|
|
302
|
+
3. Run `rails rhino:blueprint` to generate all files
|
|
303
|
+
|
|
304
|
+
## Roles Format
|
|
305
|
+
|
|
306
|
+
```yaml
|
|
307
|
+
roles:
|
|
308
|
+
owner:
|
|
309
|
+
name: Owner
|
|
310
|
+
description: "Full access"
|
|
311
|
+
viewer:
|
|
312
|
+
name: Viewer
|
|
313
|
+
description: "Read-only"
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
## Model Format
|
|
317
|
+
|
|
318
|
+
```yaml
|
|
319
|
+
model: Contract
|
|
320
|
+
slug: contracts
|
|
321
|
+
|
|
322
|
+
options:
|
|
323
|
+
belongs_to_organization: true
|
|
324
|
+
soft_deletes: true
|
|
325
|
+
|
|
326
|
+
columns:
|
|
327
|
+
title:
|
|
328
|
+
type: string
|
|
329
|
+
filterable: true
|
|
330
|
+
|
|
331
|
+
permissions:
|
|
332
|
+
owner:
|
|
333
|
+
actions: [index, show, store, update, destroy]
|
|
334
|
+
show_fields: "*"
|
|
335
|
+
create_fields: "*"
|
|
336
|
+
update_fields: "*"
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
## Valid Column Types
|
|
340
|
+
string, text, integer, bigInteger, boolean, date, datetime, timestamp, decimal, float, json, uuid, foreignId
|
|
341
|
+
|
|
342
|
+
## Valid Actions
|
|
343
|
+
index, show, store, update, destroy, trashed, restore, forceDelete
|
|
344
|
+
MD
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# ----------------------------------------------------------------
|
|
348
|
+
# AI Skill
|
|
349
|
+
# ----------------------------------------------------------------
|
|
350
|
+
|
|
351
|
+
def install_ai_skill
|
|
352
|
+
say ""
|
|
353
|
+
|
|
354
|
+
ai_tools = multi_select("Install Rhino AI Skill for which tools? (select none to skip)") do |menu|
|
|
355
|
+
menu.default 1
|
|
356
|
+
menu.choice "Claude Code (.claude/skills/rhino/)", "claude"
|
|
357
|
+
menu.choice "Cursor (.cursor/rules/rhino/)", "cursor"
|
|
358
|
+
menu.choice "AI Directory (.ai/skills/rhino/)", "ai"
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
return if ai_tools.empty?
|
|
362
|
+
|
|
363
|
+
url = "https://rhino-project.github.io/rhino-docs/skills/rails/SKILL.md"
|
|
364
|
+
|
|
365
|
+
destinations = {
|
|
366
|
+
"claude" => ".claude/skills/rhino/SKILL.md",
|
|
367
|
+
"cursor" => ".cursor/rules/rhino/SKILL.md",
|
|
368
|
+
"ai" => ".ai/skills/rhino/SKILL.md"
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
# Download once
|
|
372
|
+
require "net/http"
|
|
373
|
+
require "uri"
|
|
374
|
+
|
|
375
|
+
uri = URI.parse(url)
|
|
376
|
+
response = Net::HTTP.get_response(uri)
|
|
377
|
+
|
|
378
|
+
# Follow redirect if needed
|
|
379
|
+
if response.is_a?(Net::HTTPRedirection)
|
|
380
|
+
uri = URI.parse(response["location"])
|
|
381
|
+
response = Net::HTTP.get_response(uri)
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
385
|
+
say " Could not download skill file. You can manually download it from:", :yellow
|
|
386
|
+
say " #{url}"
|
|
387
|
+
return
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
content = response.body
|
|
391
|
+
|
|
392
|
+
ai_tools.each do |tool|
|
|
393
|
+
dest_file = Rails.root.join(destinations[tool])
|
|
394
|
+
|
|
395
|
+
task("Installing skill for #{tool}") do
|
|
396
|
+
FileUtils.mkdir_p(File.dirname(dest_file))
|
|
397
|
+
File.write(dest_file, content)
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
say " AI Skill installed successfully.", :green
|
|
402
|
+
rescue StandardError => e
|
|
403
|
+
say " Could not download skill file (#{e.message}). You can manually download it from:", :yellow
|
|
404
|
+
say " #{url}"
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rhino/commands/base_command"
|
|
4
|
+
|
|
5
|
+
module Rhino
|
|
6
|
+
module Commands
|
|
7
|
+
# Generate an invitation link for testing — mirrors Laravel `php artisan invitation:link` exactly.
|
|
8
|
+
#
|
|
9
|
+
# Usage: rails invitation:link EMAIL ORG [--role=ROLE] [--create]
|
|
10
|
+
class InvitationLinkCommand < BaseCommand
|
|
11
|
+
attr_accessor :email, :organization_identifier, :options
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
super
|
|
15
|
+
@options = { role: nil, create: false }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def perform(email = @email, organization_identifier = @organization_identifier)
|
|
19
|
+
role_identifier = options[:role]
|
|
20
|
+
should_create = options[:create]
|
|
21
|
+
|
|
22
|
+
# Find organization
|
|
23
|
+
identifier_column = Rhino.config.multi_tenant[:organization_identifier_column] || "slug"
|
|
24
|
+
|
|
25
|
+
org_class = "Organization".safe_constantize
|
|
26
|
+
unless org_class
|
|
27
|
+
say "Organization model not found.", :red
|
|
28
|
+
return
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
organization = org_class.find_by(identifier_column => organization_identifier)
|
|
32
|
+
|
|
33
|
+
unless organization
|
|
34
|
+
say "Organization '#{organization_identifier}' not found.", :red
|
|
35
|
+
return
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Find or create invitation
|
|
39
|
+
invitation = OrganizationInvitation
|
|
40
|
+
.where(email: email, organization_id: organization.id, status: "pending")
|
|
41
|
+
.first
|
|
42
|
+
|
|
43
|
+
if !invitation && !should_create
|
|
44
|
+
say "No pending invitation found for '#{email}' in organization '#{organization.name}'.", :red
|
|
45
|
+
say "Use --create flag to create a new invitation."
|
|
46
|
+
return
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
if !invitation && should_create
|
|
50
|
+
unless role_identifier
|
|
51
|
+
say "Role is required when creating a new invitation. Use --role option.", :red
|
|
52
|
+
return
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
role_class = "Role".safe_constantize
|
|
56
|
+
unless role_class
|
|
57
|
+
say "Role model not found.", :red
|
|
58
|
+
return
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
role = if role_identifier.match?(/\A\d+\z/)
|
|
62
|
+
role_class.find_by(id: role_identifier)
|
|
63
|
+
else
|
|
64
|
+
role_class.find_by(slug: role_identifier)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
unless role
|
|
68
|
+
say "Role '#{role_identifier}' not found.", :red
|
|
69
|
+
return
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
user_class = "User".safe_constantize
|
|
73
|
+
invited_by = user_class&.first
|
|
74
|
+
|
|
75
|
+
unless invited_by
|
|
76
|
+
say "No user found to assign as 'invited_by'. Please create a user first.", :red
|
|
77
|
+
return
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
invitation = OrganizationInvitation.create!(
|
|
81
|
+
organization_id: organization.id,
|
|
82
|
+
email: email,
|
|
83
|
+
role_id: role.id,
|
|
84
|
+
invited_by: invited_by.id
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
say "Created new invitation for #{email}.", :green
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Build the invitation URL
|
|
91
|
+
frontend_url = ENV.fetch("FRONTEND_URL", "http://localhost:5173")
|
|
92
|
+
url = "#{frontend_url}/accept-invitation?token=#{invitation.token}"
|
|
93
|
+
|
|
94
|
+
say ""
|
|
95
|
+
say "Invitation link for #{email}:", :green
|
|
96
|
+
say url
|
|
97
|
+
say ""
|
|
98
|
+
say "Token: #{invitation.token}"
|
|
99
|
+
say "Organization: #{organization.name} (#{organization.try(:slug) || organization.id})"
|
|
100
|
+
say "Role: #{invitation.role&.name}" if invitation.respond_to?(:role) && invitation.role
|
|
101
|
+
say "Status: #{invitation.status}"
|
|
102
|
+
say "Expires: #{invitation.expires_at&.strftime('%Y-%m-%d %H:%M:%S')}" if invitation.expires_at
|
|
103
|
+
say ""
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rhino
|
|
4
|
+
# Multi-tenant scoping concern.
|
|
5
|
+
# Mirrors the Laravel BelongsToOrganization trait.
|
|
6
|
+
#
|
|
7
|
+
# Usage:
|
|
8
|
+
# class Post < ApplicationRecord
|
|
9
|
+
# include Rhino::BelongsToOrganization
|
|
10
|
+
# end
|
|
11
|
+
#
|
|
12
|
+
# For nested ownership (model doesn't have organization_id directly),
|
|
13
|
+
# the path is auto-detected from belongs_to associations.
|
|
14
|
+
module BelongsToOrganization
|
|
15
|
+
extend ActiveSupport::Concern
|
|
16
|
+
|
|
17
|
+
included do
|
|
18
|
+
belongs_to :organization
|
|
19
|
+
|
|
20
|
+
# Auto-set organization_id on creation from request context
|
|
21
|
+
before_create :set_organization_from_context
|
|
22
|
+
|
|
23
|
+
# Default scope to filter by current organization
|
|
24
|
+
default_scope lambda {
|
|
25
|
+
if defined?(RequestStore) && RequestStore.store[:rhino_organization]
|
|
26
|
+
where(organization_id: RequestStore.store[:rhino_organization].id)
|
|
27
|
+
else
|
|
28
|
+
all
|
|
29
|
+
end
|
|
30
|
+
}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
class_methods do
|
|
34
|
+
def for_organization(organization)
|
|
35
|
+
where(organization_id: organization.id)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def set_organization_from_context
|
|
42
|
+
return if organization_id.present?
|
|
43
|
+
return unless defined?(RequestStore)
|
|
44
|
+
|
|
45
|
+
org = RequestStore.store[:rhino_organization]
|
|
46
|
+
self.organization_id = org.id if org
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rhino
|
|
4
|
+
# Automatic change logging concern.
|
|
5
|
+
# Mirrors the Laravel HasAuditTrail trait.
|
|
6
|
+
#
|
|
7
|
+
# Tracks: created, updated, deleted, force_deleted, restored
|
|
8
|
+
# Records: old/new values, user_id, organization_id, ip_address, user_agent
|
|
9
|
+
#
|
|
10
|
+
# Usage:
|
|
11
|
+
# class Post < ApplicationRecord
|
|
12
|
+
# include Rhino::HasAuditTrail
|
|
13
|
+
#
|
|
14
|
+
# # Optional: exclude sensitive fields from audit logging
|
|
15
|
+
# rhino_audit_exclude :password, :remember_token
|
|
16
|
+
# end
|
|
17
|
+
module HasAuditTrail
|
|
18
|
+
extend ActiveSupport::Concern
|
|
19
|
+
|
|
20
|
+
included do
|
|
21
|
+
class_attribute :audit_exclude_fields, default: %w[password remember_token]
|
|
22
|
+
|
|
23
|
+
has_many :audit_logs, -> { order(created_at: :desc) },
|
|
24
|
+
as: :auditable,
|
|
25
|
+
class_name: "Rhino::AuditLog",
|
|
26
|
+
dependent: :destroy
|
|
27
|
+
|
|
28
|
+
after_create :log_audit_created
|
|
29
|
+
after_update :log_audit_updated
|
|
30
|
+
after_destroy :log_audit_deleted
|
|
31
|
+
|
|
32
|
+
# For soft deletes restoration
|
|
33
|
+
if respond_to?(:after_undiscard)
|
|
34
|
+
after_undiscard :log_audit_restored
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
class_methods do
|
|
39
|
+
def rhino_audit_exclude(*fields)
|
|
40
|
+
self.audit_exclude_fields = fields.map(&:to_s)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def log_audit_created
|
|
47
|
+
log_audit("created", nil, auditable_attributes)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def log_audit_updated
|
|
51
|
+
changes = saved_changes.except("updated_at")
|
|
52
|
+
return if changes.blank?
|
|
53
|
+
|
|
54
|
+
old_values = {}
|
|
55
|
+
new_values = {}
|
|
56
|
+
|
|
57
|
+
changes.each do |field, (old_val, new_val)|
|
|
58
|
+
next if audit_exclude_fields.include?(field)
|
|
59
|
+
old_values[field] = old_val
|
|
60
|
+
new_values[field] = new_val
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
return if new_values.blank?
|
|
64
|
+
|
|
65
|
+
log_audit("updated", old_values, new_values)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def log_audit_deleted
|
|
69
|
+
action = respond_to?(:discarded?) && discarded? ? "deleted" : "force_deleted"
|
|
70
|
+
log_audit(action, auditable_attributes, nil)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def log_audit_restored
|
|
74
|
+
log_audit("restored", nil, auditable_attributes)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def log_audit(action, old_values, new_values)
|
|
78
|
+
return unless audit_log_table_exists?
|
|
79
|
+
|
|
80
|
+
attributes = {
|
|
81
|
+
auditable: self,
|
|
82
|
+
action: action,
|
|
83
|
+
old_values: old_values,
|
|
84
|
+
new_values: new_values,
|
|
85
|
+
user_id: current_audit_user_id,
|
|
86
|
+
ip_address: current_audit_ip_address,
|
|
87
|
+
user_agent: current_audit_user_agent
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
# Add organization_id if available
|
|
91
|
+
org = current_audit_organization
|
|
92
|
+
attributes[:organization_id] = org.id if org
|
|
93
|
+
|
|
94
|
+
Rhino::AuditLog.create!(attributes)
|
|
95
|
+
rescue StandardError => e
|
|
96
|
+
Rails.logger.warn("Rhino::HasAuditTrail: Failed to log audit: #{e.message}")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def auditable_attributes
|
|
100
|
+
attributes.except(*audit_exclude_fields)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def current_audit_user_id
|
|
104
|
+
RequestStore.store[:rhino_current_user]&.id if defined?(RequestStore)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def current_audit_ip_address
|
|
108
|
+
RequestStore.store[:rhino_ip_address] if defined?(RequestStore)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def current_audit_user_agent
|
|
112
|
+
RequestStore.store[:rhino_user_agent] if defined?(RequestStore)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def current_audit_organization
|
|
116
|
+
RequestStore.store[:rhino_organization] if defined?(RequestStore)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def audit_log_table_exists?
|
|
120
|
+
@_audit_log_table_exists ||= ActiveRecord::Base.connection.table_exists?("audit_logs")
|
|
121
|
+
rescue StandardError
|
|
122
|
+
false
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|