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.
Files changed (71) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +59 -0
  3. data/lib/rhino/blueprint/blueprint_parser.rb +198 -0
  4. data/lib/rhino/blueprint/blueprint_validator.rb +209 -0
  5. data/lib/rhino/blueprint/generators/factory_generator.rb +74 -0
  6. data/lib/rhino/blueprint/generators/policy_generator.rb +154 -0
  7. data/lib/rhino/blueprint/generators/seeder_generator.rb +160 -0
  8. data/lib/rhino/blueprint/generators/test_generator.rb +291 -0
  9. data/lib/rhino/blueprint/manifest_manager.rb +81 -0
  10. data/lib/rhino/commands/base_command.rb +57 -0
  11. data/lib/rhino/commands/blueprint_command.rb +529 -0
  12. data/lib/rhino/commands/export_postman_command.rb +328 -0
  13. data/lib/rhino/commands/export_types_command.rb +202 -0
  14. data/lib/rhino/commands/generate_command.rb +535 -0
  15. data/lib/rhino/commands/install_command.rb +408 -0
  16. data/lib/rhino/commands/invitation_link_command.rb +107 -0
  17. data/lib/rhino/concerns/belongs_to_organization.rb +49 -0
  18. data/lib/rhino/concerns/has_audit_trail.rb +125 -0
  19. data/lib/rhino/concerns/has_auto_scope.rb +91 -0
  20. data/lib/rhino/concerns/has_permissions.rb +117 -0
  21. data/lib/rhino/concerns/has_rhino.rb +93 -0
  22. data/lib/rhino/concerns/has_uuid.rb +26 -0
  23. data/lib/rhino/concerns/has_validation.rb +250 -0
  24. data/lib/rhino/concerns/hidable_columns.rb +180 -0
  25. data/lib/rhino/configuration.rb +101 -0
  26. data/lib/rhino/controllers/auth_controller.rb +242 -0
  27. data/lib/rhino/controllers/invitations_controller.rb +231 -0
  28. data/lib/rhino/controllers/resources_controller.rb +813 -0
  29. data/lib/rhino/engine.rb +64 -0
  30. data/lib/rhino/mailers/invitation_mailer.rb +22 -0
  31. data/lib/rhino/middleware/resolve_organization_from_route.rb +72 -0
  32. data/lib/rhino/models/audit_log.rb +17 -0
  33. data/lib/rhino/models/organization_invitation.rb +57 -0
  34. data/lib/rhino/models/rhino_model.rb +387 -0
  35. data/lib/rhino/policies/invitation_policy.rb +54 -0
  36. data/lib/rhino/policies/resource_policy.rb +197 -0
  37. data/lib/rhino/query_builder.rb +278 -0
  38. data/lib/rhino/railtie.rb +11 -0
  39. data/lib/rhino/resource_scope.rb +59 -0
  40. data/lib/rhino/routes.rb +124 -0
  41. data/lib/rhino/tasks/rhino.rake +47 -0
  42. data/lib/rhino/templates/audit_trail/create_audit_logs.rb.erb +26 -0
  43. data/lib/rhino/templates/generate/factory.rb.erb +43 -0
  44. data/lib/rhino/templates/generate/migration.rb.erb +26 -0
  45. data/lib/rhino/templates/generate/model.rb.erb +55 -0
  46. data/lib/rhino/templates/generate/policy.rb.erb +52 -0
  47. data/lib/rhino/templates/generate/scope.rb.erb +31 -0
  48. data/lib/rhino/templates/multi_tenant/factories/organizations.rb.erb +9 -0
  49. data/lib/rhino/templates/multi_tenant/factories/roles.rb.erb +9 -0
  50. data/lib/rhino/templates/multi_tenant/factories/user_roles.rb.erb +10 -0
  51. data/lib/rhino/templates/multi_tenant/factories/users.rb.erb +9 -0
  52. data/lib/rhino/templates/multi_tenant/migrations/create_organizations.rb.erb +15 -0
  53. data/lib/rhino/templates/multi_tenant/migrations/create_roles.rb.erb +15 -0
  54. data/lib/rhino/templates/multi_tenant/migrations/create_user_roles.rb.erb +16 -0
  55. data/lib/rhino/templates/multi_tenant/migrations/create_users.rb.erb +15 -0
  56. data/lib/rhino/templates/multi_tenant/models/organization.rb.erb +18 -0
  57. data/lib/rhino/templates/multi_tenant/models/role.rb.erb +11 -0
  58. data/lib/rhino/templates/multi_tenant/models/user.rb.erb +14 -0
  59. data/lib/rhino/templates/multi_tenant/models/user_role.rb.erb +9 -0
  60. data/lib/rhino/templates/multi_tenant/policies/organization_policy.rb.erb +6 -0
  61. data/lib/rhino/templates/multi_tenant/policies/role_policy.rb.erb +6 -0
  62. data/lib/rhino/templates/multi_tenant/seeders/organization_seeder.rb.erb +9 -0
  63. data/lib/rhino/templates/multi_tenant/seeders/role_seeder.rb.erb +19 -0
  64. data/lib/rhino/templates/rhino.rb +71 -0
  65. data/lib/rhino/templates/rhino_model.rb +104 -0
  66. data/lib/rhino/templates/routes.rb +13 -0
  67. data/lib/rhino/version.rb +5 -0
  68. data/lib/rhino/views/lumina/invitation_mailer/invite.html.erb +29 -0
  69. data/lib/rhino-rails.rb +3 -0
  70. data/lib/rhino.rb +26 -0
  71. metadata +282 -0
@@ -0,0 +1,328 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rhino/commands/base_command"
4
+ require "json"
5
+
6
+ module Rhino
7
+ module Commands
8
+ # Generate a Postman Collection v2.1 for all registered models.
9
+ # Mirrors Laravel `php artisan rhino:export-postman` exactly.
10
+ #
11
+ # Usage: rails rhino:export_postman [--output=postman_collection.json] [--base-url=http://localhost:3000/api]
12
+ class ExportPostmanCommand < BaseCommand
13
+ attr_accessor :options
14
+
15
+ def initialize
16
+ super
17
+ @options = {
18
+ output: "postman_collection.json",
19
+ base_url: "http://localhost:3000/api",
20
+ project_name: nil
21
+ }
22
+ end
23
+
24
+ def perform
25
+ output_path = options[:output]
26
+ base_url = options[:base_url].chomp("/")
27
+ project_name = options[:project_name] || Rails.application.class.module_parent_name rescue "API"
28
+
29
+ config = Rhino.config
30
+ route_groups = config.route_groups
31
+ all_models = config.models
32
+
33
+ has_multiple_groups = route_groups.size > 1
34
+ needs_org_variable = any_group_has_org_prefix?(route_groups)
35
+
36
+ variables = build_collection_variables(base_url, needs_org_variable)
37
+ items = []
38
+
39
+ items << build_auth_folder
40
+
41
+ if has_multiple_groups
42
+ # Multiple groups: Project -> Auth, GroupName -> resources
43
+ route_groups.each do |group_name, group_config|
44
+ group_models = resolve_models_for_group(all_models, config, group_name)
45
+ next if group_models.empty?
46
+
47
+ group_prefix = group_config[:prefix] || ""
48
+
49
+ group_items = []
50
+ group_models.each do |slug|
51
+ model_class_name = all_models[slug]
52
+ model_class = begin
53
+ model_class_name.constantize
54
+ rescue NameError
55
+ say "Model class does not exist: #{model_class_name}", :red
56
+ next
57
+ end
58
+
59
+ model_meta = introspect_model(model_class, slug)
60
+ group_items << {
61
+ name: slug.to_s,
62
+ item: build_action_folders(slug, model_meta, group_prefix)
63
+ }
64
+ end
65
+
66
+ if group_items.any?
67
+ items << {
68
+ name: group_name.to_s,
69
+ item: group_items
70
+ }
71
+ end
72
+ end
73
+ else
74
+ # Single group: Project -> Auth, resources (flat, backward compatible)
75
+ group_config = route_groups.values.first || {}
76
+ group_prefix = group_config[:prefix] || ""
77
+ group_name = route_groups.keys.first
78
+
79
+ group_models = resolve_models_for_group(all_models, config, group_name)
80
+ group_models.each do |slug|
81
+ model_class_name = all_models[slug]
82
+ model_class = begin
83
+ model_class_name.constantize
84
+ rescue NameError
85
+ say "Model class does not exist: #{model_class_name}", :red
86
+ next
87
+ end
88
+
89
+ model_meta = introspect_model(model_class, slug)
90
+ items << {
91
+ name: slug.to_s,
92
+ item: build_action_folders(slug, model_meta, group_prefix)
93
+ }
94
+ end
95
+ end
96
+
97
+ collection = {
98
+ info: {
99
+ name: project_name,
100
+ schema: "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
101
+ },
102
+ variable: variables,
103
+ item: items
104
+ }
105
+
106
+ json = JSON.pretty_generate(collection)
107
+ File.write(output_path, json)
108
+
109
+ say "Postman collection written to #{output_path}", :green
110
+ end
111
+
112
+ private
113
+
114
+ def any_group_has_org_prefix?(route_groups)
115
+ route_groups.any? { |_name, cfg| prefix_has_param?(cfg[:prefix] || "") }
116
+ end
117
+
118
+ def prefix_has_param?(prefix)
119
+ prefix.present? && prefix.include?(":")
120
+ end
121
+
122
+ def resolve_models_for_group(all_models, config, group_name)
123
+ config.models_for_group(group_name)
124
+ end
125
+
126
+ def build_collection_variables(base_url, needs_org_prefix)
127
+ vars = [
128
+ { key: "baseUrl", value: base_url },
129
+ { key: "modelId", value: "1" },
130
+ { key: "token", value: "" }
131
+ ]
132
+ vars << { key: "organization", value: "organization-1" } if needs_org_prefix
133
+ vars
134
+ end
135
+
136
+ def build_auth_folder
137
+ headers = default_headers
138
+ json_headers = headers + [{ key: "Content-Type", value: "application/json" }]
139
+
140
+ login_test = <<~JS
141
+ const json = pm.response.json();
142
+ if (json.token) {
143
+ pm.collectionVariables.set("token", json.token);
144
+ }
145
+ if (json.organization_slug) {
146
+ pm.collectionVariables.set("organization", json.organization_slug);
147
+ }
148
+ JS
149
+
150
+ {
151
+ name: "Authentication",
152
+ item: [
153
+ request_item("Login", "POST", "{{baseUrl}}/auth/login", {}, json_headers,
154
+ { email: "user@example.com", password: "password" }, login_test.strip),
155
+ request_item("Logout", "POST", "{{baseUrl}}/auth/logout", {}, headers),
156
+ request_item("Password recover", "POST", "{{baseUrl}}/auth/password/recover", {}, json_headers,
157
+ { email: "user@example.com" }),
158
+ request_item("Password reset", "POST", "{{baseUrl}}/auth/password/reset", {}, json_headers,
159
+ { token: "{{token}}", email: "user@example.com", password: "new-password", password_confirmation: "new-password" }),
160
+ request_item("Register (with invitation)", "POST", "{{baseUrl}}/auth/register", {}, json_headers,
161
+ { invitation_token: "{{token}}", name: "New User", password: "password", password_confirmation: "password" }),
162
+ request_item("Accept invitation", "POST", "{{baseUrl}}/invitations/accept", {}, json_headers,
163
+ { token: "invitation-token" })
164
+ ]
165
+ }
166
+ end
167
+
168
+ def introspect_model(model_class, slug)
169
+ {
170
+ slug: slug,
171
+ except_actions: model_class.try(:rhino_except_actions_list) || [],
172
+ uses_soft_deletes: model_class.try(:uses_soft_deletes?) || false,
173
+ allowed_filters: model_class.try(:allowed_filters) || [],
174
+ allowed_sorts: model_class.try(:allowed_sorts) || [],
175
+ allowed_fields: model_class.try(:allowed_fields) || [],
176
+ allowed_includes: model_class.try(:allowed_includes) || [],
177
+ allowed_search: model_class.try(:allowed_search) || [],
178
+ default_sort: model_class.try(:default_sort_field)
179
+ }
180
+ end
181
+
182
+ def build_action_folders(slug, meta, group_prefix)
183
+ folders = []
184
+ base = base_path(slug, group_prefix)
185
+ except = meta[:except_actions]
186
+
187
+ folders << { name: "Index", item: build_index_requests(base, slug, meta) } unless except.include?("index")
188
+ folders << { name: "Show", item: build_show_requests(base, slug, meta) } unless except.include?("show")
189
+ folders << { name: "Store", item: build_store_requests(base) } unless except.include?("store")
190
+ folders << { name: "Update", item: build_update_requests(base) } unless except.include?("update")
191
+ folders << { name: "Destroy", item: build_destroy_requests(base) } unless except.include?("destroy")
192
+
193
+ if meta[:uses_soft_deletes]
194
+ folders << { name: "Trashed", item: build_trashed_requests(base) } unless except.include?("trashed")
195
+ folders << { name: "Restore", item: build_restore_requests(base) } unless except.include?("restore")
196
+ folders << { name: "Force Delete", item: build_force_delete_requests(base) } unless except.include?("forceDelete")
197
+ end
198
+
199
+ folders
200
+ end
201
+
202
+ def base_path(slug, group_prefix)
203
+ if group_prefix.present?
204
+ # Replace :param route params with {{param}} Postman variables
205
+ postman_prefix = group_prefix.gsub(/:(\w+)/, '{{\1}}')
206
+ "{{baseUrl}}/#{postman_prefix}/#{slug}"
207
+ else
208
+ "{{baseUrl}}/#{slug}"
209
+ end
210
+ end
211
+
212
+ def build_index_requests(base, slug, meta)
213
+ headers = default_headers
214
+ requests = [request_item("List all", "GET", base, {}, headers)]
215
+
216
+ meta[:allowed_filters].each do |filter|
217
+ requests << request_item("Filter by #{filter}", "GET", base, { "filter[#{filter}]" => "example" }, headers)
218
+ end
219
+
220
+ meta[:allowed_sorts].each do |sort|
221
+ requests << request_item("Sort by #{sort} (asc)", "GET", base, { sort: sort.to_s }, headers)
222
+ requests << request_item("Sort by #{sort} (desc)", "GET", base, { sort: "-#{sort}" }, headers)
223
+ end
224
+
225
+ meta[:allowed_includes].each do |inc|
226
+ requests << request_item("Include #{inc}", "GET", base, { include: inc.to_s }, headers)
227
+ end
228
+
229
+ unless meta[:allowed_fields].empty?
230
+ requests << request_item("Select fields", "GET", base,
231
+ { "fields[#{slug}]" => meta[:allowed_fields].first(5).join(",") }, headers)
232
+ end
233
+
234
+ unless meta[:allowed_search].empty?
235
+ requests << request_item("Search", "GET", base, { search: "example" }, headers)
236
+ end
237
+
238
+ requests << request_item("Paginate", "GET", base, { per_page: "5", page: "1" }, headers)
239
+
240
+ requests
241
+ end
242
+
243
+ def build_show_requests(base, slug, meta)
244
+ path = "#{base}/{{modelId}}"
245
+ headers = default_headers
246
+ requests = [request_item("Show by ID", "GET", path, {}, headers)]
247
+
248
+ unless meta[:allowed_includes].empty?
249
+ requests << request_item("Show with include", "GET", path, { include: meta[:allowed_includes].first.to_s }, headers)
250
+ end
251
+
252
+ requests
253
+ end
254
+
255
+ def build_store_requests(base)
256
+ headers = default_headers + [{ key: "Content-Type", value: "application/json" }]
257
+ [request_item("Create", "POST", base, {}, headers, { title: "Example" })]
258
+ end
259
+
260
+ def build_update_requests(base)
261
+ path = "#{base}/{{modelId}}"
262
+ headers = default_headers + [{ key: "Content-Type", value: "application/json" }]
263
+ [request_item("Update", "PUT", path, {}, headers, { title: "Updated" })]
264
+ end
265
+
266
+ def build_destroy_requests(base)
267
+ path = "#{base}/{{modelId}}"
268
+ [request_item("Delete by ID", "DELETE", path, {}, default_headers)]
269
+ end
270
+
271
+ def build_trashed_requests(base)
272
+ [request_item("List trashed", "GET", "#{base}/trashed", {}, default_headers)]
273
+ end
274
+
275
+ def build_restore_requests(base)
276
+ [request_item("Restore by ID", "POST", "#{base}/{{modelId}}/restore", {}, default_headers)]
277
+ end
278
+
279
+ def build_force_delete_requests(base)
280
+ [request_item("Force delete by ID", "DELETE", "#{base}/{{modelId}}/force-delete", {}, default_headers)]
281
+ end
282
+
283
+ def default_headers
284
+ [
285
+ { key: "Accept", value: "application/json" },
286
+ { key: "Authorization", value: "Bearer {{token}}" }
287
+ ]
288
+ end
289
+
290
+ def request_item(name, method, path, query_params, headers, body = nil, test_script = nil)
291
+ query = query_params.map { |k, v| { key: k.to_s, value: v.to_s } }
292
+
293
+ raw = path
294
+ unless query.empty?
295
+ raw += "?" + query.map { |q| "#{q[:key]}=#{q[:value]}" }.join("&")
296
+ end
297
+
298
+ parts = path.split("/").reject(&:empty?)
299
+ url = {
300
+ raw: raw,
301
+ host: [parts.shift || "{{baseUrl}}"],
302
+ path: parts
303
+ }
304
+ url[:query] = query unless query.empty?
305
+
306
+ req = { method: method, header: headers, url: url }
307
+
308
+ if body && %w[POST PUT PATCH].include?(method)
309
+ req[:body] = {
310
+ mode: "raw",
311
+ raw: JSON.pretty_generate(body),
312
+ options: { raw: { language: "json" } }
313
+ }
314
+ end
315
+
316
+ item = { name: name }
317
+ if test_script
318
+ item[:event] = [{
319
+ listen: "test",
320
+ script: { exec: test_script.split("\n"), type: "text/javascript" }
321
+ }]
322
+ end
323
+ item[:request] = req
324
+ item
325
+ end
326
+ end
327
+ end
328
+ end
@@ -0,0 +1,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rhino/commands/base_command"
4
+ require "json"
5
+ require "tempfile"
6
+ require "fileutils"
7
+ require "shellwords"
8
+
9
+ module Rhino
10
+ module Commands
11
+ # Generate TypeScript interfaces from registered Rhino models via
12
+ # OpenAPI intermediate format + npx openapi-typescript.
13
+ #
14
+ # Mirrors the Laravel `php artisan rhino:export-types` command exactly.
15
+ #
16
+ # Usage:
17
+ # rails rhino:export_types
18
+ # rails rhino:export_types -- --output=path/to/types.d.ts
19
+ class ExportTypesCommand < BaseCommand
20
+ attr_accessor :options
21
+
22
+ def initialize
23
+ super
24
+ @options = { output: nil }
25
+ end
26
+
27
+ def perform
28
+ models = Rhino.config.models
29
+
30
+ if models.empty?
31
+ say "No models registered in Rhino configuration.", :yellow
32
+ return true
33
+ end
34
+
35
+ output_paths = resolve_output_paths
36
+
37
+ if output_paths.empty?
38
+ say "No output paths configured. Set RHINO_CLIENT_PATH and/or RHINO_MOBILE_PATH in .env, or use --output flag.", :red
39
+ return false
40
+ end
41
+
42
+ schemas = {}
43
+
44
+ models.each do |slug, model_class_name|
45
+ model_class = begin
46
+ model_class_name.constantize
47
+ rescue NameError
48
+ say "Model class does not exist: #{model_class_name}", :red
49
+ next
50
+ end
51
+
52
+ interface_name = slug_to_interface_name(slug)
53
+ properties = introspect_columns(model_class)
54
+
55
+ if properties.empty?
56
+ say "No columns found for model: #{slug} (#{model_class_name})", :yellow
57
+ next
58
+ end
59
+
60
+ schemas[interface_name] = {
61
+ type: "object",
62
+ properties: properties
63
+ }
64
+ end
65
+
66
+ if schemas.empty?
67
+ say "No schemas generated.", :yellow
68
+ return true
69
+ end
70
+
71
+ openapi_spec = build_openapi_spec(schemas)
72
+
73
+ temp_file = Tempfile.new(["rhino_openapi_", ".json"])
74
+ begin
75
+ temp_file.write(JSON.pretty_generate(openapi_spec))
76
+ temp_file.flush
77
+
78
+ output_paths.each do |output_path|
79
+ dir = File.dirname(output_path)
80
+ FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
81
+
82
+ exit_code = run_openapi_typescript(temp_file.path, output_path)
83
+
84
+ if exit_code != 0
85
+ say "Failed to generate types at #{output_path}. Is openapi-typescript installed? Run: npm install -g openapi-typescript", :red
86
+ return false
87
+ end
88
+
89
+ say "Generated TypeScript types at: #{output_path}", :green
90
+ end
91
+ ensure
92
+ temp_file.close
93
+ temp_file.unlink
94
+ end
95
+
96
+ true
97
+ end
98
+
99
+ private
100
+
101
+ # Resolve target paths from --output flag, config, or env vars.
102
+ def resolve_output_paths
103
+ explicit = options[:output]
104
+ return [explicit] if explicit
105
+
106
+ paths = []
107
+
108
+ client_path = Rhino.config.respond_to?(:client_path) && Rhino.config.client_path ||
109
+ ENV["RHINO_CLIENT_PATH"]
110
+ if client_path && !client_path.empty?
111
+ paths << File.join(client_path.chomp("/"), "src", "types", "rhino.d.ts")
112
+ end
113
+
114
+ mobile_path = Rhino.config.respond_to?(:mobile_path) && Rhino.config.mobile_path ||
115
+ ENV["RHINO_MOBILE_PATH"]
116
+ if mobile_path && !mobile_path.empty?
117
+ paths << File.join(mobile_path.chomp("/"), "src", "types", "rhino.d.ts")
118
+ end
119
+
120
+ paths
121
+ end
122
+
123
+ # Convert slug to PascalCase singular interface name.
124
+ # posts -> Post, blog_categories -> BlogCategory, blog-categories -> BlogCategory
125
+ def slug_to_interface_name(slug)
126
+ slug.to_s.underscore.singularize.camelize
127
+ end
128
+
129
+ # Introspect ActiveRecord columns and return OpenAPI property definitions.
130
+ def introspect_columns(model_class)
131
+ return {} unless model_class.respond_to?(:columns_hash)
132
+
133
+ properties = {}
134
+
135
+ model_class.columns_hash.each do |name, column|
136
+ openapi_type = map_column_type(column.type.to_s)
137
+ prop = openapi_type.dup
138
+
139
+ if column.null
140
+ prop[:nullable] = true
141
+ end
142
+
143
+ properties[name] = prop
144
+ end
145
+
146
+ properties
147
+ end
148
+
149
+ # Map ActiveRecord column type to OpenAPI type definition.
150
+ def map_column_type(db_type)
151
+ case db_type.downcase
152
+ when "integer", "int", "bigint", "smallint", "tinyint", "mediumint"
153
+ { type: "integer" }
154
+ when "decimal", "float", "double", "real", "numeric"
155
+ { type: "number" }
156
+ when "boolean", "bool"
157
+ { type: "boolean" }
158
+ when "timestamp", "datetime", "timestamptz", "date", "time"
159
+ { type: "string", format: "date-time" }
160
+ when "json", "jsonb"
161
+ { type: "object" }
162
+ else
163
+ { type: "string" }
164
+ end
165
+ end
166
+
167
+ # Build a minimal OpenAPI 3.0.3 spec containing only component schemas.
168
+ def build_openapi_spec(schemas)
169
+ app_name = begin
170
+ Rails.application.class.module_parent_name
171
+ rescue StandardError
172
+ "API"
173
+ end
174
+
175
+ {
176
+ openapi: "3.0.3",
177
+ info: {
178
+ title: "#{app_name} Models",
179
+ version: "1.0.0"
180
+ },
181
+ paths: {},
182
+ components: {
183
+ schemas: schemas
184
+ }
185
+ }
186
+ end
187
+
188
+ # Shell out to npx openapi-typescript to produce the .d.ts file.
189
+ def run_openapi_typescript(input_file, output_file)
190
+ command = "npx openapi-typescript #{Shellwords.escape(input_file)} -o #{Shellwords.escape(output_file)} 2>&1"
191
+ output = `#{command}`
192
+ exit_code = $?.exitstatus
193
+
194
+ if exit_code != 0
195
+ say output, :red
196
+ end
197
+
198
+ exit_code
199
+ end
200
+ end
201
+ end
202
+ end