plutonium 0.42.0 → 0.43.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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-controller/SKILL.md +38 -1
  3. data/.claude/skills/plutonium-definition/SKILL.md +14 -0
  4. data/.claude/skills/plutonium-forms/SKILL.md +16 -1
  5. data/.claude/skills/plutonium-profile/SKILL.md +276 -0
  6. data/.claude/skills/plutonium-views/SKILL.md +23 -1
  7. data/CHANGELOG.md +36 -0
  8. data/app/assets/plutonium.css +1 -1
  9. data/app/views/plutonium/_resource_header.html.erb +6 -27
  10. data/app/views/plutonium/_resource_sidebar.html.erb +1 -2
  11. data/app/views/resource/_resource_details.rabl +3 -2
  12. data/app/views/resource/index.rabl +3 -2
  13. data/app/views/resource/show.rabl +3 -2
  14. data/docs/guides/user-profile.md +322 -0
  15. data/docs/reference/controller/index.md +38 -1
  16. data/docs/reference/definition/index.md +16 -0
  17. data/docs/reference/views/forms.md +15 -0
  18. data/docs/reference/views/index.md +23 -1
  19. data/gemfiles/rails_7.gemfile.lock +1 -1
  20. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  21. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  22. data/lib/generators/pu/core/assets/assets_generator.rb +12 -0
  23. data/lib/generators/pu/core/install/templates/app/controllers/resource_controller.rb.tt +11 -0
  24. data/lib/generators/pu/core/typespec/templates/common.tsp.tt +95 -0
  25. data/lib/generators/pu/core/typespec/templates/main.tsp.tt +27 -0
  26. data/lib/generators/pu/core/typespec/templates/main_multi.tsp.tt +25 -0
  27. data/lib/generators/pu/core/typespec/templates/model.tsp.tt +226 -0
  28. data/lib/generators/pu/core/typespec/typespec_generator.rb +342 -0
  29. data/lib/generators/pu/invites/USAGE +0 -1
  30. data/lib/generators/pu/invites/install_generator.rb +62 -15
  31. data/lib/generators/pu/invites/templates/db/migrate/create_user_invites.rb.tt +2 -2
  32. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +2 -0
  33. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/welcome_controller.rb.tt +1 -0
  34. data/lib/generators/pu/invites/templates/packages/invites/app/models/invites/user_invite.rb.tt +5 -5
  35. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/signup.html.erb.tt +4 -4
  36. data/lib/generators/pu/lib/plutonium_generators/concerns/actions.rb +1 -1
  37. data/lib/generators/pu/lib/plutonium_generators/generator.rb +29 -0
  38. data/lib/generators/pu/lib/plutonium_generators/model_generator_base.rb +6 -23
  39. data/lib/generators/pu/pkg/portal/portal_generator.rb +5 -1
  40. data/lib/generators/pu/profile/USAGE +59 -0
  41. data/lib/generators/pu/profile/concerns/profile_arguments.rb +27 -0
  42. data/lib/generators/pu/profile/conn/USAGE +33 -0
  43. data/lib/generators/pu/profile/conn_generator.rb +167 -0
  44. data/lib/generators/pu/profile/install_generator.rb +119 -0
  45. data/lib/generators/pu/profile/setup/USAGE +42 -0
  46. data/lib/generators/pu/profile/setup_generator.rb +73 -0
  47. data/lib/generators/pu/rodauth/account_generator.rb +2 -4
  48. data/lib/generators/pu/rodauth/install_generator.rb +2 -2
  49. data/lib/generators/pu/rodauth/templates/app/rodauth/account_rodauth_plugin.rb.tt +3 -0
  50. data/lib/generators/pu/saas/api_client_generator.rb +0 -2
  51. data/lib/generators/pu/saas/membership_generator.rb +68 -19
  52. data/lib/generators/pu/saas/setup_generator.rb +7 -2
  53. data/lib/generators/pu/saas/user_generator.rb +0 -2
  54. data/lib/plutonium/auth/rodauth.rb +8 -0
  55. data/lib/plutonium/core/controller.rb +7 -4
  56. data/lib/plutonium/core/controllers/authorizable.rb +5 -1
  57. data/lib/plutonium/definition/base.rb +7 -0
  58. data/lib/plutonium/helpers/display_helper.rb +6 -0
  59. data/lib/plutonium/profile/security_section.rb +118 -0
  60. data/lib/plutonium/resource/controller.rb +17 -7
  61. data/lib/plutonium/resource/controllers/interactive_actions.rb +11 -25
  62. data/lib/plutonium/resource/controllers/presentable.rb +46 -3
  63. data/lib/plutonium/resource/record/associated_with.rb +7 -1
  64. data/lib/plutonium/routing/mapper_extensions.rb +18 -18
  65. data/lib/plutonium/routing/route_set_extensions.rb +23 -2
  66. data/lib/plutonium/ui/breadcrumbs.rb +111 -131
  67. data/lib/plutonium/ui/dyna_frame/content.rb +12 -2
  68. data/lib/plutonium/ui/form/resource.rb +26 -19
  69. data/lib/plutonium/ui/page/base.rb +14 -14
  70. data/lib/plutonium/ui/table/components/selection_column.rb +6 -2
  71. data/lib/plutonium/ui/table/resource.rb +3 -2
  72. data/lib/plutonium/version.rb +1 -1
  73. data/package.json +1 -1
  74. metadata +17 -3
  75. data/lib/generators/pu/rodauth/concerns/gem_helpers.rb +0 -19
@@ -0,0 +1,226 @@
1
+ <%-
2
+ # Helper methods for template
3
+ def optional_marker(column)
4
+ column[:null] ? '?' : ''
5
+ end
6
+
7
+ resource = @current_resource
8
+ model_name = resource[:typespec_name]
9
+ all_columns = resource[:columns]
10
+ all_column_names = all_columns.map { |c| c[:name] }
11
+
12
+ # Extract special columns if they exist
13
+ primary_key_column = all_columns.find { |c| c[:name] == resource[:primary_key] }
14
+ created_at_column = all_columns.find { |c| c[:name] == 'created_at' }
15
+ updated_at_column = all_columns.find { |c| c[:name] == 'updated_at' }
16
+
17
+ # Regular columns exclude primary key and timestamps
18
+ columns = all_columns.reject { |c| [resource[:primary_key], 'created_at', 'updated_at'].include?(c[:name]) }
19
+ associations = resource[:associations]
20
+ enums = resource[:enums] || {}
21
+ definition = resource[:definition]
22
+ inputs = definition ? definition[:inputs] : []
23
+
24
+ # Separate association columns (foreign keys) from regular columns
25
+ belongs_to_assocs = associations.select { |a| a[:macro] == 'belongs_to' }
26
+ has_many_assocs = associations.select { |a| a[:macro] == 'has_many' }
27
+ has_one_assocs = associations.select { |a| a[:macro] == 'has_one' }
28
+
29
+ # Foreign key columns to exclude from regular attributes
30
+ fk_columns = belongs_to_assocs.map { |a| a[:foreign_key] }.compact
31
+ regular_columns = columns.reject { |c| fk_columns.include?(c[:name]) }
32
+
33
+ # Check if we have definition-based inputs
34
+ has_definition_inputs = inputs.any?
35
+ -%>
36
+ /**
37
+ * <%= model_name %> resource
38
+ * Table: <%= resource[:table_name] %>
39
+ * Route: <%= resource[:route_path] %>
40
+ */
41
+
42
+ <%- if @single_portal -%>
43
+ import "../common.tsp";
44
+ <%- else -%>
45
+ import "../../common.tsp";
46
+ <%- end -%>
47
+
48
+ using TypeSpec.Http;
49
+
50
+ <%- enums.each do |enum_name, values| -%>
51
+ /** <%= enum_name.humanize %> options */
52
+ enum <%= model_name %><%= enum_name.camelize %> {
53
+ <%- values.each do |value| -%>
54
+ <%= value %>,
55
+ <%- end -%>
56
+ }
57
+
58
+ <%- end -%>
59
+ /**
60
+ * <%= model_name %> response model
61
+ */
62
+ model <%= model_name %> extends ResourceBase {
63
+ <%- if primary_key_column -%>
64
+ @doc("Unique identifier")
65
+ <%= resource[:primary_key] %>: <%= resource[:primary_key_type] %>;
66
+ <%- end -%>
67
+ <%- if created_at_column -%>
68
+ @doc("Creation timestamp")
69
+ created_at: utcDateTime;
70
+ <%- end -%>
71
+ <%- if updated_at_column -%>
72
+ @doc("Last update timestamp")
73
+ updated_at: utcDateTime;
74
+ <%- end -%>
75
+ <%- if (primary_key_column || created_at_column || updated_at_column) && regular_columns.any? -%>
76
+
77
+ <%- end -%>
78
+ <%- regular_columns.each do |column| -%>
79
+ <%- if enums.key?(column[:name]) -%>
80
+ <%= column[:name] %><%= optional_marker(column) %>: <%= model_name %><%= column[:name].camelize %>;
81
+ <%- else -%>
82
+ <%= column[:name] %><%= optional_marker(column) %>: <%= column[:typespec_type] %>;
83
+ <%- end -%>
84
+ <%- end -%>
85
+
86
+ <%- belongs_to_assocs.each do |assoc| -%>
87
+ <%- if assoc[:polymorphic] -%>
88
+ /** Polymorphic association type */
89
+ <%= assoc[:name] %>_type?: string;
90
+ /** Polymorphic association ID */
91
+ <%= assoc[:name] %>_id?: <%= assoc[:foreign_key_type] %>;
92
+ /** Polymorphic association SGID */
93
+ <%= assoc[:name] %>_sgid?: SignedGlobalId;
94
+ <%- else -%>
95
+ /** <%= assoc[:name].humanize %> ID */
96
+ <%= assoc[:foreign_key] || "#{assoc[:name]}_id" %>?: <%= assoc[:foreign_key_type] %>;
97
+ /** <%= assoc[:name].humanize %> SGID */
98
+ <%= assoc[:name] %>_sgid?: SignedGlobalId;
99
+ <%- end -%>
100
+ <%- end -%>
101
+
102
+ <%- has_many_assocs.each do |assoc| -%>
103
+ /** <%= assoc[:name].to_s.singularize.humanize %> IDs */
104
+ <%= assoc[:name].to_s.singularize %>_ids?: <%= assoc[:foreign_key_type] %>[];
105
+ /** <%= assoc[:name].to_s.singularize.humanize %> SGIDs */
106
+ <%= assoc[:name].to_s.singularize %>_sgids?: SignedGlobalId[];
107
+ <%- end -%>
108
+ <%- has_one_assocs.each do |assoc| -%>
109
+ /** <%= assoc[:name].humanize %> ID */
110
+ <%= assoc[:name] %>_id?: <%= assoc[:foreign_key_type] %>;
111
+ /** <%= assoc[:name].humanize %> SGID */
112
+ <%= assoc[:name] %>_sgid?: SignedGlobalId;
113
+ <%- end -%>
114
+ }
115
+
116
+ /**
117
+ * <%= model_name %> input model for create/update operations
118
+ <%- if has_definition_inputs -%>
119
+ * Based on definition: <%= definition[:class_name] %>
120
+ <%- end -%>
121
+ */
122
+ model <%= model_name %>Input {
123
+ <%- if has_definition_inputs -%>
124
+ <%# Use definition inputs -%>
125
+ <%- inputs.each do |input| -%>
126
+ <%- next if input[:nested] # Skip nested inputs for now -%>
127
+ <%- if input[:is_association] -%>
128
+ <%- if input[:is_polymorphic] -%>
129
+ /** Polymorphic <%= input[:name] %> (SGID) */
130
+ <%= input[:name] %>_sgid<%= input[:required] ? '' : '?' %>: SignedGlobalId;
131
+ <%- elsif input[:association_macro] == 'has_many' || input[:association_macro] == 'has_and_belongs_to_many' -%>
132
+ /** <%= input[:name].singularize.humanize %> SGIDs */
133
+ <%= input[:name].singularize %>_sgids<%= input[:required] ? '' : '?' %>: SignedGlobalId[];
134
+ <%- else -%>
135
+ /** <%= input[:name].humanize %> (SGID) */
136
+ <%= input[:name] %>_sgid<%= input[:required] ? '' : '?' %>: SignedGlobalId;
137
+ <%- end -%>
138
+ <%- else -%>
139
+ <%= input[:name] %><%= input[:required] ? '' : '?' %>: <%= input[:typespec_type] %>;
140
+ <%- end -%>
141
+ <%- end -%>
142
+ <%- else -%>
143
+ <%# Fallback to column-based inputs -%>
144
+ <%- regular_columns.each do |column| -%>
145
+ <%- next if column[:name].end_with?('_count') # skip counter caches -%>
146
+ <%- if enums.key?(column[:name]) -%>
147
+ <%= column[:name] %><%= optional_marker(column) %>: <%= model_name %><%= column[:name].camelize %>;
148
+ <%- else -%>
149
+ <%= column[:name] %><%= optional_marker(column) %>: <%= column[:typespec_type] %>;
150
+ <%- end -%>
151
+ <%- end -%>
152
+
153
+ <%- belongs_to_assocs.each do |assoc| -%>
154
+ <%- if assoc[:polymorphic] -%>
155
+ /** Polymorphic <%= assoc[:name] %> (SGID) */
156
+ <%= assoc[:name] %>_sgid?: SignedGlobalId;
157
+ <%- else -%>
158
+ /** <%= assoc[:name].humanize %> (SGID) */
159
+ <%= assoc[:name] %>_sgid?: SignedGlobalId;
160
+ <%- end -%>
161
+ <%- end -%>
162
+
163
+ <%- has_many_assocs.each do |assoc| -%>
164
+ /** <%= assoc[:name].to_s.singularize.humanize %> SGIDs */
165
+ <%= assoc[:name].to_s.singularize %>_sgids?: SignedGlobalId[];
166
+ <%- end -%>
167
+ <%- end -%>
168
+ }
169
+
170
+ /**
171
+ * <%= model_name %> list response
172
+ */
173
+ model <%= model_name %>ListResponse {
174
+ <%= model_name.underscore.pluralize %>: <%= model_name %>[];
175
+ }
176
+
177
+ /**
178
+ * <%= model_name %> CRUD operations
179
+ */
180
+ @route("<%= resource[:route_path] %>")
181
+ @tag("<%= model_name.pluralize %>")
182
+ interface <%= model_name %>Operations {
183
+ /**
184
+ * List all <%= model_name.underscore.humanize.pluralize.downcase %>
185
+ */
186
+ @get
187
+ list(...ListQueryParams): <%= model_name %>ListResponse;
188
+
189
+ /**
190
+ * Get a single <%= model_name.underscore.humanize.downcase %>
191
+ */
192
+ @route("{id}")
193
+ @get
194
+ show(@path id: <%= resource[:primary_key_type] %>): <%= model_name %>;
195
+
196
+ /**
197
+ * Create a new <%= model_name.underscore.humanize.downcase %>
198
+ */
199
+ @post
200
+ create(@body input: <%= model_name %>Input): {
201
+ @statusCode statusCode: 201;
202
+ @body body: <%= model_name %>;
203
+ } | {
204
+ @statusCode statusCode: 422;
205
+ @body body: ErrorResponse;
206
+ };
207
+
208
+ /**
209
+ * Update a <%= model_name.underscore.humanize.downcase %>
210
+ */
211
+ @route("{id}")
212
+ @patch
213
+ update(@path id: <%= resource[:primary_key_type] %>, @body input: <%= model_name %>Input): <%= model_name %> | {
214
+ @statusCode statusCode: 422;
215
+ @body body: ErrorResponse;
216
+ };
217
+
218
+ /**
219
+ * Delete a <%= model_name.underscore.humanize.downcase %>
220
+ */
221
+ @route("{id}")
222
+ @delete
223
+ destroy(@path id: <%= resource[:primary_key_type] %>): {
224
+ @statusCode statusCode: 204;
225
+ };
226
+ }
@@ -0,0 +1,342 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../lib/plutonium_generators"
4
+
5
+ module Pu
6
+ module Core
7
+ class TypespecGenerator < Rails::Generators::Base
8
+ include PlutoniumGenerators::Generator
9
+
10
+ source_root File.expand_path("templates", __dir__)
11
+
12
+ desc "Generate TypeSpec API specifications from Plutonium resources"
13
+
14
+ class_option :output, type: :string, default: "typespec", desc: "Output directory for TypeSpec files"
15
+ class_option :portal, type: :string, desc: "Generate specs for a specific portal only"
16
+
17
+ def start
18
+ load_application
19
+ check_pending_migrations!
20
+ detect_portals
21
+ generate_typespec_files
22
+ end
23
+
24
+ private
25
+
26
+ attr_reader :portals
27
+
28
+ def load_application
29
+ say_status :loading, "Rails application", :blue
30
+ Rails.application.eager_load!
31
+ Rails.application.reload_routes!
32
+ end
33
+
34
+ def check_pending_migrations!
35
+ context = ActiveRecord::MigrationContext.new(ActiveRecord::Migrator.migrations_paths)
36
+ pending = context.migrations.select { |m| !context.get_all_versions.include?(m.version) }
37
+
38
+ return if pending.empty?
39
+
40
+ say_status :error, "Pending migrations detected!", :red
41
+ pending.each do |migration|
42
+ say_status :pending, "#{migration.version} - #{migration.name}", :yellow
43
+ end
44
+ say ""
45
+ say "Run `bin/rails db:migrate` before generating TypeSpec specifications.", :red
46
+ raise Thor::Error, "Cannot generate TypeSpec with pending migrations"
47
+ end
48
+
49
+ def detect_portals
50
+ say_status :detecting, "portals", :blue
51
+ @portals = []
52
+
53
+ Rails::Engine.subclasses.each do |engine|
54
+ next unless engine.included_modules.any? { |m| m.name&.include?("Plutonium::Portal") }
55
+
56
+ portal_name = engine.name.sub(/::Engine$/, "")
57
+ route_path = find_engine_route_path(engine)
58
+
59
+ next if options[:portal] && portal_name.underscore != options[:portal].underscore
60
+
61
+ @portals << build_portal_data(engine, portal_name, route_path)
62
+ end
63
+
64
+ if @portals.empty?
65
+ say_status :warning, "No portals found#{" matching '#{options[:portal]}'" if options[:portal]}", :yellow
66
+ else
67
+ @portals.each do |portal|
68
+ say_status :found, "#{portal[:name]} (#{portal[:resources].size} resources)", :green
69
+ end
70
+ end
71
+ end
72
+
73
+ def find_engine_route_path(engine)
74
+ Rails.application.routes.routes.find do |route|
75
+ route.app.app == engine
76
+ end&.path&.spec&.to_s&.gsub(/\(\.:format\)$/, "")
77
+ end
78
+
79
+ def build_portal_data(engine, portal_name, route_path)
80
+ resources = []
81
+
82
+ engine.resource_register.resources.each do |resource|
83
+ route_config = engine.routes.resource_route_config_lookup[resource.model_name.plural]
84
+ next unless route_config
85
+
86
+ resource_data = build_resource_data(resource, route_config, portal_name, route_path)
87
+ resources << resource_data if resource_data
88
+ end
89
+
90
+ {
91
+ name: portal_name,
92
+ engine: engine,
93
+ route_path: route_path || "/#{portal_name.underscore}",
94
+ file_name: portal_name.underscore.tr("/", "_"),
95
+ resources: resources
96
+ }
97
+ end
98
+
99
+ def build_resource_data(resource, route_config, portal_name, portal_route_path)
100
+ return nil unless resource.table_exists?
101
+
102
+ # Try portal-specific definition first, then fall back to base definition
103
+ definition_class = safe_constantize("#{portal_name}::#{resource.name.demodulize}Definition") ||
104
+ safe_constantize("#{resource.name}Definition")
105
+
106
+ resource_path = route_config[:route_options][:path]
107
+ full_route = [portal_route_path, resource_path].compact.join("/").gsub(%r{//+}, "/")
108
+
109
+ {
110
+ name: resource.name,
111
+ typespec_name: resource.name.demodulize,
112
+ file_name: resource.name.underscore.tr("/", "_"),
113
+ table_name: resource.table_name,
114
+ route_path: full_route,
115
+ primary_key: resource.primary_key,
116
+ primary_key_type: primary_key_type(resource),
117
+ columns: build_columns_data(resource),
118
+ associations: build_associations_data(resource),
119
+ enums: build_enums_data(resource),
120
+ definition: definition_class ? build_definition_data(definition_class, resource) : nil
121
+ }
122
+ rescue NameError => e
123
+ say_status :skip, "#{resource.name}: #{e.message}", :yellow
124
+ nil
125
+ end
126
+
127
+ def primary_key_type(resource)
128
+ column_to_typespec_type(resource.columns.find { |c| c.name == resource.primary_key })
129
+ end
130
+
131
+ def column_to_typespec_type(column)
132
+ return "int64" unless column
133
+ TYPE_MAPPING[column.type] || "int64"
134
+ end
135
+
136
+ def build_columns_data(resource)
137
+ resource.columns.map do |column|
138
+ {
139
+ name: column.name,
140
+ type: column.type.to_s,
141
+ null: column.null,
142
+ default: column.default,
143
+ typespec_type: TYPE_MAPPING[column.type] || "string"
144
+ }
145
+ end
146
+ end
147
+
148
+ def build_associations_data(resource)
149
+ resource.reflect_on_all_associations.map do |assoc|
150
+ fk_type = resolve_foreign_key_type(assoc, resource)
151
+
152
+ {
153
+ name: assoc.name.to_s,
154
+ macro: assoc.macro.to_s,
155
+ class_name: safe_association_attr(assoc, :klass)&.name,
156
+ foreign_key: safe_association_attr(assoc, :foreign_key),
157
+ foreign_key_type: fk_type,
158
+ polymorphic: !!assoc.polymorphic?
159
+ }
160
+ end
161
+ end
162
+
163
+ def resolve_foreign_key_type(assoc, resource)
164
+ return "int64" if assoc.polymorphic?
165
+
166
+ # For belongs_to, look up the associated model's primary key type
167
+ if assoc.macro == :belongs_to
168
+ target_class = safe_association_attr(assoc, :klass)
169
+ if target_class&.table_exists?
170
+ pk_column = target_class.columns.find { |c| c.name == target_class.primary_key }
171
+ return column_to_typespec_type(pk_column)
172
+ end
173
+ end
174
+
175
+ # For has_many/has_one, use this resource's primary key type
176
+ primary_key_type(resource)
177
+ end
178
+
179
+ def safe_association_attr(assoc, attr)
180
+ assoc.public_send(attr)
181
+ rescue NameError
182
+ nil
183
+ end
184
+
185
+ def build_enums_data(resource)
186
+ return {} unless resource.respond_to?(:defined_enums)
187
+ resource.defined_enums.transform_values(&:keys)
188
+ end
189
+
190
+ def build_definition_data(definition_class, resource)
191
+ inputs = extract_inputs(definition_class, resource)
192
+
193
+ {
194
+ class_name: definition_class.name,
195
+ inputs: inputs
196
+ }
197
+ end
198
+
199
+ def extract_inputs(definition_class, resource)
200
+ return [] unless definition_class.respond_to?(:defined_inputs)
201
+
202
+ defined_inputs = definition_class.defined_inputs
203
+ return [] if defined_inputs.empty?
204
+
205
+ defined_inputs.map do |name, config|
206
+ input_config = config[:options] || {}
207
+ column = resource.columns_hash[name.to_s]
208
+ assoc = resource.reflect_on_association(name)
209
+
210
+ {
211
+ name: name.to_s,
212
+ as: input_config[:as]&.to_s,
213
+ required: !input_config[:optional],
214
+ type: determine_input_type(name, input_config, column, assoc, resource),
215
+ typespec_type: determine_typespec_input_type(name, input_config, column, assoc, resource),
216
+ is_association: assoc.present?,
217
+ is_polymorphic: assoc&.polymorphic?,
218
+ association_macro: assoc&.macro&.to_s,
219
+ nested: input_config[:nested].present?
220
+ }
221
+ end
222
+ end
223
+
224
+ def determine_input_type(name, config, column, assoc, resource)
225
+ return config[:as].to_s if config[:as]
226
+ return "association" if assoc
227
+ return "enum" if resource.defined_enums.key?(name.to_s)
228
+ return column.type.to_s if column
229
+ "string"
230
+ end
231
+
232
+ def determine_typespec_input_type(name, config, column, assoc, resource)
233
+ # Associations use SGIDs
234
+ if assoc
235
+ return "SignedGlobalId[]" if %i[has_many has_and_belongs_to_many].include?(assoc.macro)
236
+ return "SignedGlobalId"
237
+ end
238
+
239
+ # Enums use the enum type
240
+ return "#{resource.name.demodulize}#{name.to_s.camelize}" if resource.defined_enums.key?(name.to_s)
241
+
242
+ # Use column type
243
+ return TYPE_MAPPING[column.type] || "string" if column
244
+
245
+ # Infer from as: option
246
+ AS_TYPE_MAPPING[config[:as]&.to_sym] || "string"
247
+ end
248
+
249
+ def generate_typespec_files
250
+ say_status :generating, "TypeSpec files", :blue
251
+
252
+ empty_directory output_dir
253
+ @single_portal = @portals.size == 1
254
+ template "common.tsp.tt", "#{output_dir}/common.tsp"
255
+
256
+ if @single_portal
257
+ # Single portal - generate flat structure
258
+ @current_portal = @portals.first
259
+ generate_portal_files(@current_portal, output_dir)
260
+ else
261
+ # Multiple portals - generate per-portal directories
262
+ @portals.each do |portal|
263
+ portal_dir = "#{output_dir}/#{portal[:file_name]}"
264
+ empty_directory portal_dir
265
+ generate_portal_files(portal, portal_dir)
266
+ end
267
+
268
+ # Generate root main.tsp that imports all portals
269
+ template "main_multi.tsp.tt", "#{output_dir}/main.tsp"
270
+ end
271
+
272
+ say_status :complete, "TypeSpec files generated in #{output_dir}/", :green
273
+ end
274
+
275
+ def generate_portal_files(portal, dir)
276
+ @current_portal = portal
277
+ template "main.tsp.tt", "#{dir}/main.tsp"
278
+
279
+ empty_directory "#{dir}/models"
280
+
281
+ portal[:resources].each do |resource|
282
+ @current_resource = resource
283
+ template "model.tsp.tt", "#{dir}/models/#{resource[:file_name]}.tsp"
284
+ end
285
+ end
286
+
287
+ def output_dir
288
+ options[:output]
289
+ end
290
+
291
+ def safe_constantize(name)
292
+ name.constantize
293
+ rescue NameError
294
+ nil
295
+ end
296
+
297
+ TYPE_MAPPING = {
298
+ string: "string",
299
+ text: "string",
300
+ integer: "int32",
301
+ bigint: "int64",
302
+ float: "float64",
303
+ decimal: "decimal",
304
+ boolean: "boolean",
305
+ date: "plainDate",
306
+ datetime: "utcDateTime",
307
+ time: "plainTime",
308
+ binary: "bytes",
309
+ json: "Record<string, unknown>",
310
+ jsonb: "Record<string, unknown>",
311
+ uuid: "string",
312
+ hstore: "Record<string, string>"
313
+ }.freeze
314
+
315
+ AS_TYPE_MAPPING = {
316
+ text: "string",
317
+ textarea: "string",
318
+ markdown: "string",
319
+ rich_text: "string",
320
+ number: "int32",
321
+ integer: "int32",
322
+ decimal: "decimal",
323
+ boolean: "boolean",
324
+ checkbox: "boolean",
325
+ date: "plainDate",
326
+ datetime: "utcDateTime",
327
+ time: "plainTime",
328
+ file: "bytes",
329
+ attachment: "bytes",
330
+ email: "string",
331
+ url: "url",
332
+ phone: "string",
333
+ password: "string",
334
+ color: "string",
335
+ json: "Record<string, unknown>",
336
+ jsonb: "Record<string, unknown>",
337
+ hstore: "Record<string, string>",
338
+ key_value: "Record<string, string>"
339
+ }.freeze
340
+ end
341
+ end
342
+ end
@@ -18,7 +18,6 @@ Options:
18
18
  --entity-model=NAME Entity model name for scoping (default: Entity)
19
19
  --user-model=NAME User model name (default: User)
20
20
  --membership-model=NAME Membership model name (default: <Entity>User)
21
- --roles=ROLES Comma-separated roles (default: member,admin)
22
21
  --rodauth=NAME Rodauth configuration for signup (default: user)
23
22
  --enforce-domain Require user email domain to match entity domain
24
23