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,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentCode
4
+ module Blueprint
5
+ module Generators
6
+ # Generates fully working Ruby policy classes with role-based attribute permissions.
7
+ # Port of agentcode-adonis-server policy_generator.ts.
8
+ class PolicyGenerator
9
+ # Generate a complete policy class.
10
+ #
11
+ # @param blueprint [Hash] ParsedBlueprint
12
+ # @return [String] Ruby source code
13
+ def generate(blueprint)
14
+ model_name = blueprint[:model]
15
+ slug = blueprint[:slug]
16
+ permissions = blueprint[:permissions]
17
+
18
+ show_method = build_permitted_attributes_method("permitted_attributes_for_show", permissions, :show_fields)
19
+ hidden_method = build_hidden_attributes_method(permissions)
20
+ create_method = build_permitted_attributes_method("permitted_attributes_for_create", permissions, :create_fields)
21
+ update_method = build_permitted_attributes_method("permitted_attributes_for_update", permissions, :update_fields)
22
+
23
+ <<~RUBY
24
+ # frozen_string_literal: true
25
+
26
+ class #{model_name}Policy < AgentCode::ResourcePolicy
27
+ self.resource_slug = '#{slug}'
28
+
29
+ #{show_method}
30
+ #{hidden_method}
31
+ #{create_method}
32
+ #{update_method}
33
+ end
34
+ RUBY
35
+ end
36
+
37
+ # Group roles with identical field sets into combined conditions.
38
+ #
39
+ # @param permissions [Hash<String, Hash>]
40
+ # @param field_key [Symbol] :show_fields, :create_fields, etc.
41
+ # @return [Array<Hash>] [{ fields: [...], roles: [...] }, ...]
42
+ def group_roles_by_fields(permissions, field_key)
43
+ groups = {}
44
+
45
+ permissions.each do |role, perm|
46
+ fields = perm[field_key] || []
47
+ next if fields.empty?
48
+
49
+ key = fields.sort.join(",")
50
+ groups[key] ||= { fields: fields, roles: [] }
51
+ groups[key][:roles] << role
52
+ end
53
+
54
+ groups.values
55
+ end
56
+
57
+ # Build a role condition string.
58
+ #
59
+ # @param roles [Array<String>]
60
+ # @return [String] e.g. "has_role?(user, 'admin') || has_role?(user, 'editor')"
61
+ def build_role_condition(roles)
62
+ roles.map { |r| "has_role?(user, '#{r}')" }.join(" || ")
63
+ end
64
+
65
+ # Convert field array to Ruby array literal.
66
+ #
67
+ # @param fields [Array<String>]
68
+ # @return [String]
69
+ def fields_to_ruby_array(fields)
70
+ return "[]" if fields.empty?
71
+ return "['*']" if fields == ["*"]
72
+
73
+ items = fields.map { |f| "'#{f}'" }
74
+ inline = "[#{items.join(', ')}]"
75
+
76
+ if inline.length <= 80
77
+ inline
78
+ else
79
+ lines = items.join(",\n ")
80
+ "[\n #{lines},\n ]"
81
+ end
82
+ end
83
+
84
+ # Build a permitted_attributes method body.
85
+ #
86
+ # @param method_name [String]
87
+ # @param permissions [Hash<String, Hash>]
88
+ # @param field_key [Symbol]
89
+ # @return [String]
90
+ def build_permitted_attributes_method(method_name, permissions, field_key)
91
+ groups = group_roles_by_fields(permissions, field_key)
92
+
93
+ if groups.empty?
94
+ return <<~RUBY.chomp
95
+ def #{method_name}(user)
96
+ ['*']
97
+ end
98
+ RUBY
99
+ end
100
+
101
+ lines = []
102
+ lines << " def #{method_name}(user)"
103
+
104
+ groups.each do |group|
105
+ condition = build_role_condition(group[:roles])
106
+ array_str = fields_to_ruby_array(group[:fields])
107
+ lines << " return #{array_str} if #{condition}"
108
+ end
109
+
110
+ lines << " []"
111
+ lines << " end"
112
+
113
+ lines.join("\n")
114
+ end
115
+
116
+ # Build the hidden_attributes_for_show method.
117
+ #
118
+ # @param permissions [Hash<String, Hash>]
119
+ # @return [String]
120
+ def build_hidden_attributes_method(permissions)
121
+ # Filter to only roles with hidden_fields
122
+ hidden_perms = permissions.select { |_, perm| perm[:hidden_fields]&.any? }
123
+
124
+ if hidden_perms.empty?
125
+ return <<~RUBY.chomp
126
+ def hidden_attributes_for_show(user)
127
+ []
128
+ end
129
+ RUBY
130
+ end
131
+
132
+ groups = group_roles_by_fields(
133
+ hidden_perms.transform_values { |p| { show_fields: p[:hidden_fields] } },
134
+ :show_fields
135
+ )
136
+
137
+ lines = []
138
+ lines << " def hidden_attributes_for_show(user)"
139
+
140
+ groups.each do |group|
141
+ condition = build_role_condition(group[:roles])
142
+ array_str = fields_to_ruby_array(group[:fields])
143
+ lines << " return #{array_str} if #{condition}"
144
+ end
145
+
146
+ lines << " []"
147
+ lines << " end"
148
+
149
+ lines.join("\n")
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentCode
4
+ module Blueprint
5
+ module Generators
6
+ # Generates seeder files for roles and users with aggregated permissions.
7
+ # Port of agentcode-adonis-server seeder_generator.ts.
8
+ class SeederGenerator
9
+ ALL_ACTIONS = %w[index show store update destroy trashed restore forceDelete].freeze
10
+
11
+ # Generate a RoleSeeder file for multi-tenant apps.
12
+ #
13
+ # @param roles [Hash<String, Hash>]
14
+ # @return [String] Ruby source code
15
+ def generate_role_seeder(roles)
16
+ role_entries = roles.map do |slug, role|
17
+ desc = role[:description].gsub("'", "\\\\'")
18
+ <<~RUBY.chomp
19
+ Role.find_or_create_by!(slug: '#{slug}') do |r|
20
+ r.name = '#{role[:name]}'
21
+ r.description = '#{desc}'
22
+ end
23
+ RUBY
24
+ end.join("\n\n")
25
+
26
+ <<~RUBY
27
+ # frozen_string_literal: true
28
+
29
+
30
+ #{role_entries}
31
+ RUBY
32
+ end
33
+
34
+ # Generate a UserRoleSeeder file for multi-tenant apps.
35
+ #
36
+ # @param roles [Hash<String, Hash>]
37
+ # @param aggregated_permissions [Hash<String, Array<String>>]
38
+ # @return [String] Ruby source code
39
+ def generate_user_role_seeder(roles, aggregated_permissions)
40
+ user_entries = roles.map do |slug, role|
41
+ permissions = aggregated_permissions[slug] || []
42
+ perm_str = permissions_to_ruby_array(permissions)
43
+
44
+ <<~RUBY.chomp
45
+ # #{role[:name]}
46
+ #{slug}_user = User.find_or_create_by!(email: '#{slug}@demo.com') do |u|
47
+ u.password = 'password'
48
+ end
49
+ #{slug}_role = Role.find_by!(slug: '#{slug}')
50
+ UserRole.find_or_create_by!(
51
+ user: #{slug}_user,
52
+ organization: org,
53
+ role: #{slug}_role
54
+ ) do |ur|
55
+ ur.permissions = #{perm_str}
56
+ end
57
+ RUBY
58
+ end.join("\n\n")
59
+
60
+ <<~RUBY
61
+ # frozen_string_literal: true
62
+
63
+
64
+ org = Organization.find_or_create_by!(slug: 'demo-org') do |o|
65
+ o.name = 'Demo Organization'
66
+ end
67
+
68
+ #{user_entries}
69
+ RUBY
70
+ end
71
+
72
+ # Generate a UserPermissionSeeder file for non-tenant apps.
73
+ #
74
+ # @param roles [Hash<String, Hash>]
75
+ # @param aggregated_permissions [Hash<String, Array<String>>]
76
+ # @return [String] Ruby source code
77
+ def generate_user_permission_seeder(roles, aggregated_permissions)
78
+ user_entries = roles.map do |slug, role|
79
+ permissions = aggregated_permissions[slug] || []
80
+ perm_str = permissions_to_ruby_array(permissions)
81
+
82
+ <<~RUBY.chomp
83
+ # #{role[:name]}
84
+ User.find_or_create_by!(email: '#{slug}@demo.com') do |u|
85
+ u.password = 'password'
86
+ u.permissions = #{perm_str}
87
+ end
88
+ RUBY
89
+ end.join("\n\n")
90
+
91
+ <<~RUBY
92
+ # frozen_string_literal: true
93
+
94
+
95
+ #{user_entries}
96
+ RUBY
97
+ end
98
+
99
+ # Aggregate permissions from multiple blueprints.
100
+ # Returns { role_slug => ['model.action', ...] } with wildcard simplification.
101
+ #
102
+ # @param blueprints [Array<Hash>]
103
+ # @return [Hash<String, Array<String>>]
104
+ def aggregate_permissions(blueprints)
105
+ result = {}
106
+
107
+ blueprints.each do |blueprint|
108
+ slug = blueprint[:slug]
109
+
110
+ blueprint[:permissions].each do |role, perm|
111
+ result[role] ||= Set.new
112
+
113
+ has_all = ALL_ACTIONS.all? { |a| perm[:actions].include?(a) }
114
+
115
+ if has_all
116
+ result[role].add("#{slug}.*")
117
+ else
118
+ perm[:actions].each { |action| result[role].add("#{slug}.#{action}") }
119
+ end
120
+ end
121
+ end
122
+
123
+ # Simplify: if a role has wildcard on ALL models → ['*']
124
+ model_slugs = blueprints.map { |b| b[:slug] }
125
+ final_result = {}
126
+
127
+ result.each do |role, perms|
128
+ perm_array = perms.to_a.sort
129
+
130
+ has_all_wildcards = model_slugs.all? { |s| perms.include?("#{s}.*") }
131
+
132
+ if has_all_wildcards && model_slugs.any?
133
+ final_result[role] = ["*"]
134
+ else
135
+ final_result[role] = perm_array
136
+ end
137
+ end
138
+
139
+ final_result
140
+ end
141
+
142
+ # Convert permission array to Ruby array literal.
143
+ def permissions_to_ruby_array(permissions)
144
+ return "[]" if permissions.empty?
145
+ return "['*']" if permissions.length == 1 && permissions[0] == "*"
146
+
147
+ items = permissions.map { |p| "'#{p}'" }
148
+ inline = "[#{items.join(', ')}]"
149
+
150
+ if inline.length <= 80
151
+ inline
152
+ else
153
+ lines = items.join(",\n ")
154
+ "[\n #{lines},\n ]"
155
+ end
156
+ end
157
+ end
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,291 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentCode
4
+ module Blueprint
5
+ module Generators
6
+ # Generates RSpec request spec files with per-role contexts and individual action tests.
7
+ class TestGenerator
8
+ ALL_ACTIONS = %w[index show store update destroy trashed restore forceDelete].freeze
9
+
10
+ ACTION_LABELS = {
11
+ "index" => "list",
12
+ "show" => "show",
13
+ "store" => "create",
14
+ "update" => "update",
15
+ "destroy" => "delete",
16
+ "trashed" => "view trashed",
17
+ "restore" => "restore",
18
+ "forceDelete" => "force delete"
19
+ }.freeze
20
+
21
+ def generate(blueprint, is_multi_tenant, org_identifier = "slug")
22
+ model = blueprint[:model]
23
+ slug = blueprint[:slug]
24
+ permissions = blueprint[:permissions]
25
+ columns = blueprint[:columns]
26
+ factory_name = model_to_factory(model)
27
+
28
+ role_contexts = build_role_contexts(slug, factory_name, permissions, columns, is_multi_tenant, org_identifier)
29
+
30
+ if is_multi_tenant
31
+ wrap_multi_tenant(model, slug, role_contexts, org_identifier)
32
+ else
33
+ wrap_non_tenant(model, slug, role_contexts)
34
+ end
35
+ end
36
+
37
+ def actions_to_permissions(actions, slug)
38
+ if (ALL_ACTIONS - actions).empty?
39
+ "['#{slug}.*']"
40
+ else
41
+ items = actions.map { |a| "'#{slug}.#{a}'" }
42
+ "[#{items.join(', ')}]"
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def model_to_factory(model)
49
+ model.gsub(/([a-z])([A-Z])/, '\1_\2').downcase
50
+ end
51
+
52
+ def build_role_contexts(slug, factory_name, permissions, columns, is_multi_tenant, org_identifier)
53
+ return "" if permissions.empty?
54
+
55
+ all_defined_actions = permissions.values.flat_map { |p| p[:actions] }.uniq & ALL_ACTIONS
56
+ lines = []
57
+
58
+ permissions.each do |role, perm|
59
+ allowed = perm[:actions] & all_defined_actions
60
+ blocked = all_defined_actions - perm[:actions]
61
+
62
+ lines << " context 'as #{role}' do"
63
+ lines << build_let_user(role, perm[:actions], slug, is_multi_tenant, org_identifier)
64
+ lines << build_let_record(factory_name, is_multi_tenant)
65
+ lines << ""
66
+
67
+ # Individual allowed action tests
68
+ allowed.each do |action|
69
+ lines << build_single_action_test(slug, action, is_multi_tenant, org_identifier, true)
70
+ lines << ""
71
+ end
72
+
73
+ # Individual blocked action tests
74
+ blocked.each do |action|
75
+ lines << build_single_action_test(slug, action, is_multi_tenant, org_identifier, false)
76
+ lines << ""
77
+ end
78
+
79
+ # Field visibility tests
80
+ field_test = build_field_visibility_test(slug, role, perm, columns, is_multi_tenant, org_identifier)
81
+ if field_test
82
+ lines << field_test
83
+ lines << ""
84
+ end
85
+
86
+ # Forbidden field tests
87
+ forbidden_test = build_forbidden_field_test(slug, role, perm, columns, is_multi_tenant, org_identifier)
88
+ if forbidden_test
89
+ lines << forbidden_test
90
+ lines << ""
91
+ end
92
+
93
+ lines << " end"
94
+ lines << ""
95
+ end
96
+
97
+ lines.join("\n")
98
+ end
99
+
100
+ def build_let_user(role, actions, slug, is_multi_tenant, org_identifier)
101
+ perms = actions_to_permissions(actions, slug)
102
+ if is_multi_tenant
103
+ " let(:user) { create_user_with_role('#{role}', org, #{perms}) }"
104
+ else
105
+ " let(:user) { create_user_with_permissions(#{perms}) }"
106
+ end
107
+ end
108
+
109
+ def build_let_record(factory_name, is_multi_tenant)
110
+ if is_multi_tenant
111
+ " let(:record) { create(:#{factory_name}, organization: org) }"
112
+ else
113
+ " let(:record) { create(:#{factory_name}) }"
114
+ end
115
+ end
116
+
117
+ def build_single_action_test(slug, action, is_multi_tenant, org_identifier, expect_success)
118
+ id_actions = %w[show update destroy restore forceDelete]
119
+ needs_id = id_actions.include?(action)
120
+ needs_discard = %w[restore forceDelete].include?(action)
121
+
122
+ action_methods = {
123
+ "index" => "get", "show" => "get", "store" => "post",
124
+ "update" => "put", "destroy" => "delete", "trashed" => "get",
125
+ "restore" => "post", "forceDelete" => "delete"
126
+ }
127
+
128
+ action_path_suffix = {
129
+ "index" => "", "show" => "/\#{record.id}", "store" => "",
130
+ "update" => "/\#{record.id}", "destroy" => "/\#{record.id}",
131
+ "trashed" => "/trashed", "restore" => "/\#{record.id}/restore",
132
+ "forceDelete" => "/\#{record.id}/force-delete"
133
+ }
134
+
135
+ success_codes = { "store" => ":created", "destroy" => ":no_content", "forceDelete" => ":no_content" }
136
+ default_success = ":ok"
137
+
138
+ http_method = action_methods[action]
139
+ suffix = action_path_suffix[action]
140
+ return "" unless http_method
141
+
142
+ label = ACTION_LABELS[action] || action
143
+
144
+ if is_multi_tenant
145
+ url = "\"/api/\#{org.#{org_identifier}}/#{slug}#{suffix}\""
146
+ else
147
+ url = "\"/api/#{slug}#{suffix}\""
148
+ end
149
+
150
+ if expect_success
151
+ code = success_codes[action] || default_success
152
+ verb = "can"
153
+ else
154
+ code = ":forbidden"
155
+ verb = "cannot"
156
+ end
157
+
158
+ lines = []
159
+ lines << " it '#{verb} #{label} #{slug}' do"
160
+ lines << " record.discard" if needs_discard
161
+ lines << " #{http_method} #{url}, headers: auth_headers(user)"
162
+ if expect_success && action == "store"
163
+ lines << " expect(response.status).not_to eq(403)"
164
+ else
165
+ lines << " expect(response).to have_http_status(#{code})"
166
+ end
167
+ lines << " end"
168
+ lines.join("\n")
169
+ end
170
+
171
+ def build_field_visibility_test(slug, role, perm, columns, is_multi_tenant, org_identifier)
172
+ return nil unless perm[:actions].include?("show")
173
+ return nil if perm[:show_fields] == ["*"]
174
+ return nil if perm[:show_fields].empty?
175
+
176
+ all_fields = columns.map { |c| c[:name] }
177
+ visible = perm[:show_fields].include?("*") ? all_fields : perm[:show_fields]
178
+ hidden = all_fields - visible + (perm[:hidden_fields] || [])
179
+ hidden = hidden.uniq - visible
180
+
181
+ return nil if hidden.empty? && visible.empty?
182
+
183
+ lines = []
184
+ lines << " it 'shows only permitted fields' do"
185
+
186
+ if is_multi_tenant
187
+ lines << " get \"/api/\#{org.#{org_identifier}}/#{slug}/\#{record.id}\", headers: auth_headers(user)"
188
+ else
189
+ lines << " get \"/api/#{slug}/\#{record.id}\", headers: auth_headers(user)"
190
+ end
191
+
192
+ lines << " expect(response).to have_http_status(:ok)"
193
+ lines << " data = JSON.parse(response.body)"
194
+ lines << ""
195
+
196
+ visible.each do |field|
197
+ lines << " expect(data).to have_key('#{field}')"
198
+ end
199
+
200
+ if hidden.any?
201
+ lines << ""
202
+ hidden.each do |field|
203
+ lines << " expect(data).not_to have_key('#{field}')"
204
+ end
205
+ end
206
+
207
+ lines << " end"
208
+ lines.join("\n")
209
+ end
210
+
211
+ def build_forbidden_field_test(slug, role, perm, columns, is_multi_tenant, org_identifier)
212
+ return nil unless perm[:actions].include?("store")
213
+ return nil if perm[:create_fields] == ["*"]
214
+ return nil if perm[:create_fields].empty?
215
+
216
+ all_fields = columns.map { |c| c[:name] }
217
+ forbidden = all_fields - perm[:create_fields]
218
+
219
+ return nil if forbidden.empty?
220
+
221
+ lines = []
222
+ lines << " it 'returns 403 when setting restricted fields' do"
223
+
224
+ if is_multi_tenant
225
+ lines << " post \"/api/\#{org.#{org_identifier}}/#{slug}\","
226
+ else
227
+ lines << " post \"/api/#{slug}\","
228
+ end
229
+
230
+ lines << " params: { #{forbidden.first}: 'forbidden_value' },"
231
+ lines << " headers: auth_headers(user)"
232
+ lines << ""
233
+ lines << " expect(response).to have_http_status(:forbidden)"
234
+ lines << " end"
235
+ lines.join("\n")
236
+ end
237
+
238
+ def wrap_multi_tenant(model, slug, role_contexts, org_identifier)
239
+ <<~RUBY
240
+ # frozen_string_literal: true
241
+
242
+ require 'rails_helper'
243
+
244
+ RSpec.describe '#{model} — CRUD & Permissions', type: :request do
245
+ let(:org) { create(:organization) }
246
+
247
+ def create_user_with_role(role_slug, organization, permissions)
248
+ user = create(:user)
249
+ role = Role.find_or_create_by!(slug: role_slug, name: role_slug.capitalize)
250
+ UserRole.find_or_create_by!(
251
+ user: user,
252
+ organization: organization,
253
+ role: role
254
+ ) do |ur|
255
+ ur.permissions = permissions
256
+ end
257
+ user
258
+ end
259
+
260
+ def auth_headers(user)
261
+ { 'Authorization' => "Bearer \#{user.api_token}" }
262
+ end
263
+
264
+ #{role_contexts}
265
+ end
266
+ RUBY
267
+ end
268
+
269
+ def wrap_non_tenant(model, slug, role_contexts)
270
+ <<~RUBY
271
+ # frozen_string_literal: true
272
+
273
+ require 'rails_helper'
274
+
275
+ RSpec.describe '#{model} — CRUD & Permissions', type: :request do
276
+ def create_user_with_permissions(permissions)
277
+ create(:user, permissions: permissions)
278
+ end
279
+
280
+ def auth_headers(user)
281
+ { 'Authorization' => "Bearer \#{user.api_token}" }
282
+ end
283
+
284
+ #{role_contexts}
285
+ end
286
+ RUBY
287
+ end
288
+ end
289
+ end
290
+ end
291
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module AgentCode
6
+ module Blueprint
7
+ # Tracks file hashes and generated files for change detection.
8
+ # Port of agentcode-server ManifestManager.php / agentcode-adonis-server manifest_manager.ts.
9
+ class ManifestManager
10
+ def initialize(blueprints_dir)
11
+ @manifest_path = File.join(blueprints_dir, ".blueprint-manifest.json")
12
+ @manifest = load_manifest
13
+ end
14
+
15
+ # Check if a blueprint file has changed since last generation.
16
+ def has_changed?(filename, current_hash)
17
+ entry = @manifest.dig("files", filename)
18
+ return true unless entry
19
+
20
+ entry["content_hash"] != current_hash
21
+ end
22
+
23
+ # Record a successful generation.
24
+ def record_generation(filename, content_hash, generated_files)
25
+ @manifest["files"][filename] = {
26
+ "content_hash" => content_hash,
27
+ "generated_files" => generated_files,
28
+ "generated_at" => Time.now.iso8601
29
+ }
30
+ @manifest["generated_at"] = Time.now.iso8601
31
+ end
32
+
33
+ # Get the list of generated files for a blueprint.
34
+ def get_generated_files(filename)
35
+ @manifest.dig("files", filename, "generated_files") || []
36
+ end
37
+
38
+ # Get all tracked blueprint filenames.
39
+ def get_tracked_files
40
+ @manifest["files"].keys
41
+ end
42
+
43
+ # Remove tracking for a blueprint file.
44
+ def remove_tracking(filename)
45
+ @manifest["files"].delete(filename)
46
+ end
47
+
48
+ # Save manifest to disk.
49
+ def save
50
+ File.write(@manifest_path, JSON.pretty_generate(@manifest))
51
+ end
52
+
53
+ # Load manifest from disk, returning empty structure if missing or corrupted.
54
+ def load_manifest
55
+ return empty_manifest unless File.exist?(@manifest_path)
56
+
57
+ begin
58
+ parsed = JSON.parse(File.read(@manifest_path))
59
+
60
+ if parsed.is_a?(Hash) && parsed["version"] && parsed["files"]
61
+ parsed
62
+ else
63
+ empty_manifest
64
+ end
65
+ rescue JSON::ParserError
66
+ empty_manifest
67
+ end
68
+ end
69
+
70
+ private
71
+
72
+ def empty_manifest
73
+ {
74
+ "version" => 1,
75
+ "generated_at" => Time.now.iso8601,
76
+ "files" => {}
77
+ }
78
+ end
79
+ end
80
+ end
81
+ end