agentcode 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 (70) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +59 -0
  3. data/lib/agentcode/blueprint/blueprint_parser.rb +198 -0
  4. data/lib/agentcode/blueprint/blueprint_validator.rb +209 -0
  5. data/lib/agentcode/blueprint/generators/factory_generator.rb +74 -0
  6. data/lib/agentcode/blueprint/generators/policy_generator.rb +154 -0
  7. data/lib/agentcode/blueprint/generators/seeder_generator.rb +160 -0
  8. data/lib/agentcode/blueprint/generators/test_generator.rb +291 -0
  9. data/lib/agentcode/blueprint/manifest_manager.rb +81 -0
  10. data/lib/agentcode/commands/base_command.rb +57 -0
  11. data/lib/agentcode/commands/blueprint_command.rb +549 -0
  12. data/lib/agentcode/commands/export_postman_command.rb +328 -0
  13. data/lib/agentcode/commands/generate_command.rb +563 -0
  14. data/lib/agentcode/commands/install_command.rb +441 -0
  15. data/lib/agentcode/commands/invitation_link_command.rb +107 -0
  16. data/lib/agentcode/concerns/belongs_to_organization.rb +49 -0
  17. data/lib/agentcode/concerns/has_agentcode.rb +93 -0
  18. data/lib/agentcode/concerns/has_audit_trail.rb +125 -0
  19. data/lib/agentcode/concerns/has_auto_scope.rb +91 -0
  20. data/lib/agentcode/concerns/has_permissions.rb +117 -0
  21. data/lib/agentcode/concerns/has_uuid.rb +26 -0
  22. data/lib/agentcode/concerns/has_validation.rb +250 -0
  23. data/lib/agentcode/concerns/hidable_columns.rb +180 -0
  24. data/lib/agentcode/configuration.rb +98 -0
  25. data/lib/agentcode/controllers/auth_controller.rb +242 -0
  26. data/lib/agentcode/controllers/invitations_controller.rb +231 -0
  27. data/lib/agentcode/controllers/resources_controller.rb +813 -0
  28. data/lib/agentcode/engine.rb +65 -0
  29. data/lib/agentcode/mailers/invitation_mailer.rb +22 -0
  30. data/lib/agentcode/middleware/resolve_organization_from_route.rb +72 -0
  31. data/lib/agentcode/models/agentcode_model.rb +387 -0
  32. data/lib/agentcode/models/audit_log.rb +17 -0
  33. data/lib/agentcode/models/organization_invitation.rb +57 -0
  34. data/lib/agentcode/policies/invitation_policy.rb +54 -0
  35. data/lib/agentcode/policies/resource_policy.rb +197 -0
  36. data/lib/agentcode/query_builder.rb +278 -0
  37. data/lib/agentcode/railtie.rb +11 -0
  38. data/lib/agentcode/resource_scope.rb +59 -0
  39. data/lib/agentcode/routes.rb +124 -0
  40. data/lib/agentcode/tasks/agentcode.rake +39 -0
  41. data/lib/agentcode/templates/agentcode.rb +71 -0
  42. data/lib/agentcode/templates/agentcode_model.rb +104 -0
  43. data/lib/agentcode/templates/audit_trail/create_audit_logs.rb.erb +26 -0
  44. data/lib/agentcode/templates/generate/factory.rb.erb +43 -0
  45. data/lib/agentcode/templates/generate/migration.rb.erb +26 -0
  46. data/lib/agentcode/templates/generate/model.rb.erb +55 -0
  47. data/lib/agentcode/templates/generate/policy.rb.erb +52 -0
  48. data/lib/agentcode/templates/generate/scope.rb.erb +31 -0
  49. data/lib/agentcode/templates/multi_tenant/factories/organizations.rb.erb +9 -0
  50. data/lib/agentcode/templates/multi_tenant/factories/roles.rb.erb +9 -0
  51. data/lib/agentcode/templates/multi_tenant/factories/user_roles.rb.erb +10 -0
  52. data/lib/agentcode/templates/multi_tenant/factories/users.rb.erb +9 -0
  53. data/lib/agentcode/templates/multi_tenant/migrations/create_organizations.rb.erb +15 -0
  54. data/lib/agentcode/templates/multi_tenant/migrations/create_roles.rb.erb +15 -0
  55. data/lib/agentcode/templates/multi_tenant/migrations/create_user_roles.rb.erb +16 -0
  56. data/lib/agentcode/templates/multi_tenant/migrations/create_users.rb.erb +15 -0
  57. data/lib/agentcode/templates/multi_tenant/models/organization.rb.erb +18 -0
  58. data/lib/agentcode/templates/multi_tenant/models/role.rb.erb +11 -0
  59. data/lib/agentcode/templates/multi_tenant/models/user.rb.erb +14 -0
  60. data/lib/agentcode/templates/multi_tenant/models/user_role.rb.erb +9 -0
  61. data/lib/agentcode/templates/multi_tenant/policies/organization_policy.rb.erb +6 -0
  62. data/lib/agentcode/templates/multi_tenant/policies/role_policy.rb.erb +6 -0
  63. data/lib/agentcode/templates/multi_tenant/seeders/organization_seeder.rb.erb +9 -0
  64. data/lib/agentcode/templates/multi_tenant/seeders/role_seeder.rb.erb +19 -0
  65. data/lib/agentcode/templates/routes.rb +13 -0
  66. data/lib/agentcode/version.rb +5 -0
  67. data/lib/agentcode/views/lumina/invitation_mailer/invite.html.erb +29 -0
  68. data/lib/agentcode-rails.rb +3 -0
  69. data/lib/agentcode.rb +26 -0
  70. metadata +281 -0
@@ -0,0 +1,328 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "agentcode/commands/base_command"
4
+ require "json"
5
+
6
+ module AgentCode
7
+ module Commands
8
+ # Generate a Postman Collection v2.1 for all registered models.
9
+ # Mirrors Laravel `php artisan agentcode:export-postman` exactly.
10
+ #
11
+ # Usage: rails agentcode: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 = AgentCode.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(:agentcode_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