ruby_cms 0.1.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 (131) hide show
  1. checksums.yaml +7 -0
  2. data/.cursor/dhh.mdc +698 -0
  3. data/.ruby-version +1 -0
  4. data/CHANGELOG.md +5 -0
  5. data/CODE_OF_CONDUCT.md +10 -0
  6. data/README.md +235 -0
  7. data/Rakefile +30 -0
  8. data/app/components/ruby_cms/admin/admin_page/admin_table_content.rb +32 -0
  9. data/app/components/ruby_cms/admin/admin_page.rb +345 -0
  10. data/app/components/ruby_cms/admin/base_component.rb +78 -0
  11. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table.rb +149 -0
  12. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_actions.rb +127 -0
  13. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_body.rb +15 -0
  14. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_checkbox_cell.rb +41 -0
  15. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_checkbox_head.rb +33 -0
  16. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_delete_modal.rb +174 -0
  17. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_header.rb +59 -0
  18. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_header_bar.rb +159 -0
  19. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_pagination.rb +192 -0
  20. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_row.rb +97 -0
  21. data/app/components/ruby_cms/admin/bulk_action_table/bulk_actions.rb +137 -0
  22. data/app/controllers/concerns/ruby_cms/admin_pagination.rb +120 -0
  23. data/app/controllers/concerns/ruby_cms/admin_turbo_table.rb +68 -0
  24. data/app/controllers/concerns/ruby_cms/page_tracking.rb +52 -0
  25. data/app/controllers/concerns/ruby_cms/visitor_error_capture.rb +39 -0
  26. data/app/controllers/ruby_cms/admin/analytics_controller.rb +191 -0
  27. data/app/controllers/ruby_cms/admin/base_controller.rb +105 -0
  28. data/app/controllers/ruby_cms/admin/content_blocks_controller.rb +390 -0
  29. data/app/controllers/ruby_cms/admin/dashboard_controller.rb +50 -0
  30. data/app/controllers/ruby_cms/admin/locale_controller.rb +20 -0
  31. data/app/controllers/ruby_cms/admin/permissions_controller.rb +66 -0
  32. data/app/controllers/ruby_cms/admin/settings_controller.rb +223 -0
  33. data/app/controllers/ruby_cms/admin/user_permissions_controller.rb +59 -0
  34. data/app/controllers/ruby_cms/admin/users_controller.rb +107 -0
  35. data/app/controllers/ruby_cms/admin/visitor_errors_controller.rb +89 -0
  36. data/app/controllers/ruby_cms/admin/visual_editor_controller.rb +322 -0
  37. data/app/controllers/ruby_cms/errors_controller.rb +35 -0
  38. data/app/helpers/ruby_cms/admin/admin_page_helper.rb +21 -0
  39. data/app/helpers/ruby_cms/admin/bulk_action_table_helper.rb +159 -0
  40. data/app/helpers/ruby_cms/application_helper.rb +41 -0
  41. data/app/helpers/ruby_cms/bulk_action_table_helper.rb +151 -0
  42. data/app/helpers/ruby_cms/content_blocks_helper.rb +375 -0
  43. data/app/helpers/ruby_cms/settings_helper.rb +160 -0
  44. data/app/javascript/controllers/ruby_cms/auto_save_preference_controller.js +73 -0
  45. data/app/javascript/controllers/ruby_cms/bulk_action_table_controller.js +553 -0
  46. data/app/javascript/controllers/ruby_cms/clickable_row_controller.js +28 -0
  47. data/app/javascript/controllers/ruby_cms/flash_messages_controller.js +29 -0
  48. data/app/javascript/controllers/ruby_cms/index.js +104 -0
  49. data/app/javascript/controllers/ruby_cms/locale_tabs_controller.js +34 -0
  50. data/app/javascript/controllers/ruby_cms/mobile_menu_controller.js +55 -0
  51. data/app/javascript/controllers/ruby_cms/nav_order_sortable_controller.js +192 -0
  52. data/app/javascript/controllers/ruby_cms/page_preview_controller.js +135 -0
  53. data/app/javascript/controllers/ruby_cms/toggle_controller.js +39 -0
  54. data/app/javascript/controllers/ruby_cms/visual_editor_controller.js +321 -0
  55. data/app/models/concerns/content_block/publishable.rb +54 -0
  56. data/app/models/concerns/content_block/searchable.rb +22 -0
  57. data/app/models/content_block.rb +155 -0
  58. data/app/models/ruby_cms/content_block.rb +8 -0
  59. data/app/models/ruby_cms/permission.rb +28 -0
  60. data/app/models/ruby_cms/permittable.rb +39 -0
  61. data/app/models/ruby_cms/preference.rb +111 -0
  62. data/app/models/ruby_cms/user_permission.rb +12 -0
  63. data/app/models/ruby_cms/visitor_error.rb +109 -0
  64. data/app/services/ruby_cms/analytics/report.rb +362 -0
  65. data/app/services/ruby_cms/security_tracker.rb +92 -0
  66. data/app/views/layouts/ruby_cms/_admin_flash_messages.html.erb +37 -0
  67. data/app/views/layouts/ruby_cms/_admin_sidebar.html.erb +121 -0
  68. data/app/views/layouts/ruby_cms/admin.html.erb +81 -0
  69. data/app/views/layouts/ruby_cms/minimal.html.erb +181 -0
  70. data/app/views/ruby_cms/admin/analytics/index.html.erb +160 -0
  71. data/app/views/ruby_cms/admin/analytics/page_details.html.erb +84 -0
  72. data/app/views/ruby_cms/admin/analytics/partials/_back_button.html.erb +3 -0
  73. data/app/views/ruby_cms/admin/analytics/partials/_browser_device.html.erb +40 -0
  74. data/app/views/ruby_cms/admin/analytics/partials/_daily_activity_chart.html.erb +58 -0
  75. data/app/views/ruby_cms/admin/analytics/partials/_hourly_activity_chart.html.erb +51 -0
  76. data/app/views/ruby_cms/admin/analytics/partials/_recent_activity.html.erb +31 -0
  77. data/app/views/ruby_cms/admin/analytics/partials/_security_alert.html.erb +4 -0
  78. data/app/views/ruby_cms/admin/analytics/partials/_top_referrers.html.erb +21 -0
  79. data/app/views/ruby_cms/admin/analytics/visitor_details.html.erb +125 -0
  80. data/app/views/ruby_cms/admin/content_blocks/_form.html.erb +161 -0
  81. data/app/views/ruby_cms/admin/content_blocks/_row.html.erb +25 -0
  82. data/app/views/ruby_cms/admin/content_blocks/edit.html.erb +17 -0
  83. data/app/views/ruby_cms/admin/content_blocks/index.html.erb +66 -0
  84. data/app/views/ruby_cms/admin/content_blocks/new.html.erb +5 -0
  85. data/app/views/ruby_cms/admin/content_blocks/show.html.erb +110 -0
  86. data/app/views/ruby_cms/admin/dashboard/index.html.erb +198 -0
  87. data/app/views/ruby_cms/admin/permissions/_row.html.erb +11 -0
  88. data/app/views/ruby_cms/admin/permissions/index.html.erb +62 -0
  89. data/app/views/ruby_cms/admin/settings/index.html.erb +220 -0
  90. data/app/views/ruby_cms/admin/shared/_bulk_action_table_index.html.erb +56 -0
  91. data/app/views/ruby_cms/admin/user_permissions/index.html.erb +55 -0
  92. data/app/views/ruby_cms/admin/users/_row.html.erb +14 -0
  93. data/app/views/ruby_cms/admin/users/index.html.erb +70 -0
  94. data/app/views/ruby_cms/admin/visitor_errors/_row.html.erb +35 -0
  95. data/app/views/ruby_cms/admin/visitor_errors/index.html.erb +57 -0
  96. data/app/views/ruby_cms/admin/visitor_errors/show.html.erb +147 -0
  97. data/app/views/ruby_cms/admin/visual_editor/index.html.erb +144 -0
  98. data/app/views/ruby_cms/errors/not_found.html.erb +92 -0
  99. data/config/database.yml +6 -0
  100. data/config/importmap.rb +36 -0
  101. data/config/locales/en.yml +101 -0
  102. data/config/routes.rb +65 -0
  103. data/db/migrate/20260125000001_create_ruby_cms_permissions.rb +14 -0
  104. data/db/migrate/20260125000002_create_ruby_cms_user_permissions.rb +14 -0
  105. data/db/migrate/20260125000003_create_ruby_cms_content_blocks.rb +19 -0
  106. data/db/migrate/20260125000010_add_indexes_to_ruby_cms_tables.rb +9 -0
  107. data/db/migrate/20260127000001_add_locale_to_ruby_cms_content_blocks.rb +34 -0
  108. data/db/migrate/20260129000001_create_ruby_cms_visitor_errors.rb +24 -0
  109. data/db/migrate/20260130000001_add_referer_and_query_to_ruby_cms_visitor_errors.rb +8 -0
  110. data/db/migrate/20260130000002_create_ruby_cms_preferences.rb +16 -0
  111. data/db/migrate/20260130000003_add_category_to_ruby_cms_preferences.rb +8 -0
  112. data/db/migrate/20260211000001_add_ruby_cms_analytics_fields_to_ahoy_events.rb +19 -0
  113. data/db/migrate/20260212000001_use_unprefixed_cms_tables.rb +146 -0
  114. data/exe/ruby_cms +25 -0
  115. data/lib/generators/ruby_cms/install_generator.rb +1062 -0
  116. data/lib/generators/ruby_cms/templates/admin.html.erb +82 -0
  117. data/lib/generators/ruby_cms/templates/ruby_cms.rb +86 -0
  118. data/lib/ruby_cms/app_integration.rb +82 -0
  119. data/lib/ruby_cms/cli.rb +169 -0
  120. data/lib/ruby_cms/content_blocks_grouping.rb +41 -0
  121. data/lib/ruby_cms/content_blocks_sync.rb +329 -0
  122. data/lib/ruby_cms/css_compiler.rb +35 -0
  123. data/lib/ruby_cms/engine.rb +498 -0
  124. data/lib/ruby_cms/settings.rb +145 -0
  125. data/lib/ruby_cms/settings_registry.rb +289 -0
  126. data/lib/ruby_cms/version.rb +5 -0
  127. data/lib/ruby_cms.rb +195 -0
  128. data/lib/tasks/ruby_cms.rake +27 -0
  129. data/log/test.log +17875 -0
  130. data/sig/ruby_cms.rbs +4 -0
  131. metadata +223 -0
@@ -0,0 +1,1062 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module RubyCms
6
+ module Generators
7
+ class InstallGenerator < ::Rails::Generators::Base
8
+ source_root File.expand_path("templates", __dir__)
9
+
10
+ NEXT_STEPS_MESSAGE = <<~TEXT
11
+
12
+ ✓ RubyCMS install complete.
13
+
14
+ Next steps (if not already done):
15
+ - rails db:migrate
16
+ - rails ruby_cms:seed_permissions (includes manage_visitor_errors)
17
+ - rails ruby_cms:setup_admin (or: rails ruby_cms:grant_manage_admin email=you@example.com)
18
+ - To seed content blocks from YAML: add content under content_blocks in config/locales/<locale>.yml, then run rails ruby_cms:content_blocks:seed (or call it from db/seeds.rb).
19
+
20
+ Notes:
21
+ - If the host uses /admin already, remove or change those routes.
22
+ - Avoid root to: redirect("/admin") — use a real root or ruby_cms.unauthorized_redirect_path.
23
+ - Review config/initializers/ruby_cms.rb (session, CSP).
24
+ - Add 'css: bin/rails tailwindcss:watch' to Procfile.dev for Tailwind in development.
25
+ - Visit /admin (sign in as the admin you configured).
26
+
27
+ Tracking:
28
+ - Visitor errors: Automatically captured via ApplicationController (see /admin/visitor_errors)
29
+ - Page views (Ahoy): Include RubyCms::PageTracking in your public controllers to track page views
30
+ Example: class PagesController < ApplicationController; include RubyCms::PageTracking; end
31
+ - Analytics: View visit/event data in Ahoy tables (ahoy_visits, ahoy_events)
32
+ TEXT
33
+
34
+ def run_authentication
35
+ user_path = Rails.root.join("app/models/user.rb")
36
+ return if File.exist?(user_path)
37
+
38
+ say "ℹ Task authentication: User model not found. " \
39
+ "Running 'rails g authentication' (Rails 8+).", :cyan
40
+ @authentication_attempted = true
41
+ run "bin/rails generate authentication"
42
+ run "bundle install"
43
+ rescue StandardError => e
44
+ say "⚠ Could not run 'rails g authentication': #{e.message}.", :yellow
45
+ say " On Rails 8+, run 'rails g authentication' and 'bundle install' manually.", :yellow
46
+ end
47
+
48
+ def verify_auth
49
+ verify_user_model
50
+ verify_session_model
51
+ verify_application_controller
52
+ end
53
+
54
+ def verify_user_model
55
+ return if defined?(::User)
56
+
57
+ message = if @authentication_attempted
58
+ "Run 'rails db:migrate' if the authentication generator succeeded."
59
+ else
60
+ "User model not found. Run 'rails g authentication' before using /admin."
61
+ end
62
+ say "ℹ Task authentication: #{message}", :yellow
63
+ end
64
+
65
+ def verify_session_model
66
+ return if defined?(::Session)
67
+
68
+ say "ℹ Task authentication: Session model not found. " \
69
+ "The host app should provide authentication.",
70
+ :yellow
71
+ end
72
+
73
+ def verify_application_controller
74
+ ac_path = Rails.root.join("app/controllers/application_controller.rb")
75
+ return unless ac_path.exist?
76
+
77
+ content = File.read(ac_path)
78
+ return if content.include?("include Authentication")
79
+ return if @authentication_warning_shown
80
+
81
+ say "ℹ Task authentication: ApplicationController does not include " \
82
+ "Authentication. Ensure /admin is protected.",
83
+ :yellow
84
+ @authentication_warning_shown = true
85
+ end
86
+
87
+ def create_initializer
88
+ @detected_pages = detect_page_templates
89
+ template "ruby_cms.rb", "config/initializers/ruby_cms.rb"
90
+ end
91
+
92
+ def mount_engine
93
+ route 'mount RubyCms::Engine => "/"'
94
+ end
95
+
96
+ def add_catch_all_route
97
+ routes_path = Rails.root.join("config/routes.rb")
98
+ return unless routes_path.exist?
99
+
100
+ content = File.read(routes_path)
101
+ return if content.include?("ruby_cms/errors#not_found")
102
+
103
+ # Add catch-all route at the end of the routes block (before final 'end')
104
+ catch_all = <<~ROUTE
105
+
106
+ # RubyCMS: Catch-all route for 404 error tracking (must be LAST)
107
+ match "*path", to: "ruby_cms/errors#not_found", via: :all,
108
+ constraints: ->(req) { !req.path.start_with?("/rails/", "/assets/") }
109
+ ROUTE
110
+
111
+ # Insert before the last 'end' in the file
112
+ gsub_file routes_path, /(\nend)\s*\z/ do
113
+ "#{catch_all}end\n"
114
+ end
115
+ say "✓ Catch-all route: Added for 404 error tracking", :green
116
+ rescue StandardError => e
117
+ say "⚠ Catch-all route: Could not add automatically: #{e.message}. " \
118
+ "Add manually at the END of routes.rb:\n " \
119
+ 'match "*path", to: "ruby_cms/errors#not_found", via: :all, ' \
120
+ 'constraints: ->(req) { !req.path.start_with?("/rails/", "/assets/") }',
121
+ :yellow
122
+ end
123
+
124
+ def add_permittable_to_user
125
+ user_path = Rails.root.join("app/models/user.rb")
126
+ unless File.exist?(user_path)
127
+ say "Skipping User: app/models/user.rb not found.", :yellow
128
+ return
129
+ end
130
+
131
+ return if File.read(user_path).include?("RubyCms::Permittable")
132
+
133
+ inject_into_file user_path, after: /class User .*\n/ do
134
+ " include RubyCms::Permittable\n"
135
+ end
136
+ end
137
+
138
+ def add_current_user_to_authentication
139
+ auth_path = Rails.root.join("app/controllers/concerns/authentication.rb")
140
+ return unless auth_path.exist?
141
+ return if File.read(auth_path).include?("def current_user")
142
+
143
+ gsub_file auth_path, " helper_method :authenticated?\n",
144
+ " helper_method :authenticated?, :current_user\n"
145
+ inject_into_file auth_path, after: " private\n" do
146
+ " def current_user\n Current.user\n end\n\n"
147
+ end
148
+ end
149
+
150
+ def add_visitor_error_capture
151
+ ac_path = Rails.root.join("app/controllers/application_controller.rb")
152
+ return unless ac_path.exist?
153
+
154
+ content = File.read(ac_path)
155
+ return if content.include?("RubyCms::VisitorErrorCapture")
156
+
157
+ to_inject = " include RubyCms::VisitorErrorCapture\n"
158
+ to_inject += " rescue_from StandardError, with: :handle_visitor_error\n" \
159
+ unless content.include?("rescue_from StandardError")
160
+
161
+ inject_into_file ac_path, after: /class ApplicationController.*\n/ do
162
+ to_inject
163
+ end
164
+ say "✓ Visitor error capture: Added to ApplicationController", :green
165
+ rescue StandardError => e
166
+ say "⚠ Visitor error capture: Could not add to ApplicationController: #{e.message}. " \
167
+ "Add manually: include RubyCms::VisitorErrorCapture and rescue_from StandardError, with: :handle_visitor_error",
168
+ :yellow
169
+ end
170
+
171
+ def add_page_tracking_to_home_controller
172
+ home_path = Rails.root.join("app/controllers/home_controller.rb")
173
+ return unless home_path.exist?
174
+
175
+ content = File.read(home_path)
176
+ return if content.include?("RubyCms::PageTracking")
177
+
178
+ inject_into_file home_path, after: /class HomeController.*\n/ do
179
+ " include RubyCms::PageTracking\n"
180
+ end
181
+
182
+ say "✓ Page tracking: Added RubyCms::PageTracking to HomeController", :green
183
+ rescue StandardError => e
184
+ say "⚠ Page tracking: Could not add to HomeController: #{e.message}. " \
185
+ "Add manually: include RubyCms::PageTracking",
186
+ :yellow
187
+ end
188
+
189
+ def copy_fallback_css
190
+ src_dir = RubyCms::Engine.root.join("app/assets/stylesheets/ruby_cms")
191
+ dest_dir = Rails.root.join("app/assets/stylesheets/ruby_cms")
192
+
193
+ return unless src_dir.exist?
194
+
195
+ FileUtils.mkdir_p(dest_dir)
196
+ copy_admin_css(dest_dir)
197
+ # Don't copy component files - only the compiled admin.css is needed
198
+ # copy_components_css(src_dir, dest_dir)
199
+ say "✓ Task css/copy: Combined component CSS into " \
200
+ "app/assets/stylesheets/ruby_cms/admin.css", :green
201
+ rescue StandardError => e
202
+ say "⚠ Task css/copy: Could not copy CSS files: #{e.message}.", :yellow
203
+ end
204
+
205
+ def create_admin_layout
206
+ layout_path = Rails.root.join("app/views/layouts/admin.html.erb")
207
+ return if File.exist?(layout_path)
208
+
209
+ template "admin.html.erb", layout_path.to_s
210
+ say "✓ Layout admin: Created app/views/layouts/admin.html.erb", :green
211
+ rescue StandardError => e
212
+ say "⚠ Layout admin: Could not create admin.html.erb: #{e.message}. " \
213
+ "Create it manually using the RubyCMS template.", :yellow
214
+ end
215
+ no_tasks do
216
+ def copy_admin_css(dest_dir)
217
+ admin_css_dest = dest_dir.join("admin.css")
218
+ RubyCms::Engine.compile_admin_css(admin_css_dest)
219
+ end
220
+
221
+ def copy_components_css(src_dir, dest_dir)
222
+ components_src_dir = src_dir.join("components")
223
+ components_dest_dir = dest_dir.join("components")
224
+ return unless components_src_dir.exist? && components_src_dir.directory?
225
+
226
+ FileUtils.mkdir_p(components_dest_dir)
227
+ Dir.glob(components_src_dir.join("*.css")).each do |src_file|
228
+ copy_component_css_file(src_file, components_dest_dir)
229
+ end
230
+ end
231
+
232
+ def copy_component_css_file(src_file, dest_dir)
233
+ filename = File.basename(src_file)
234
+ dest_file = dest_dir.join(filename)
235
+ return if dest_file.exist? && File.mtime(src_file) <= File.mtime(dest_file)
236
+
237
+ FileUtils.cp(src_file, dest_file, preserve: true)
238
+ end
239
+ end
240
+
241
+ def install_ahoy
242
+ if ahoy_already_installed?
243
+ say "ℹ Task ahoy: Existing Ahoy setup detected (tables or migrations). Skipping ahoy:install.",
244
+ :cyan
245
+ configure_ahoy_server_side_only
246
+ return
247
+ end
248
+
249
+ say "ℹ Task ahoy: Installing Ahoy for visit/event tracking.", :cyan
250
+ run "bin/rails generate ahoy:install"
251
+ add_ahoy_security_fields_migration
252
+ configure_ahoy_server_side_only
253
+ say "✓ Task ahoy: Installed Ahoy (visits, events, tracking)", :green
254
+ rescue StandardError => e
255
+ say "⚠ Task ahoy: Could not install: #{e.message}. " \
256
+ "Run 'rails g ahoy:install' manually.",
257
+ :yellow
258
+ end
259
+
260
+ def install_action_text
261
+ migrate_dir = Rails.root.join("db/migrate")
262
+ return unless migrate_dir.directory?
263
+ return if action_text_already_installed?(migrate_dir)
264
+
265
+ say "ℹ Task action_text: Installing Action Text for rich text/image content blocks.", :cyan
266
+ run "bin/rails action_text:install"
267
+ say "✓ Task action_text: Installed Action Text", :green
268
+ rescue StandardError => e
269
+ say "⚠ Task action_text: Could not install: #{e.message}. Rich text will be disabled.",
270
+ :yellow
271
+ end
272
+
273
+ no_tasks do
274
+ def ahoy_already_installed?
275
+ return true if ahoy_tables_exist?
276
+
277
+ migrate_dir = Rails.root.join("db/migrate")
278
+ return false unless migrate_dir.directory?
279
+
280
+ Dir.glob(migrate_dir.join("*.rb").to_s).any? do |f|
281
+ content = File.read(f)
282
+ content.include?("ahoy_visits") || content.include?("ahoy_events")
283
+ end
284
+ end
285
+
286
+ def ahoy_tables_exist?
287
+ return false unless defined?(ActiveRecord::Base)
288
+
289
+ # Calling connection lazily establishes a connection when possible,
290
+ # so we can detect existing Ahoy tables even before AR reports connected?.
291
+ c = ActiveRecord::Base.connection
292
+ c.data_source_exists?("ahoy_visits") || c.data_source_exists?("ahoy_events")
293
+ rescue ActiveRecord::ConnectionNotEstablished, ActiveRecord::NoDatabaseError,
294
+ ActiveRecord::StatementInvalid
295
+ false
296
+ end
297
+
298
+ def add_ahoy_security_fields_migration
299
+ run "bin/rails generate migration AddRubyCmsFieldsToAhoyEvents"
300
+ migration_file = Rails.root.glob("db/migrate/*add_ruby_cms*fields*.rb").max_by(&:basename)
301
+ return unless migration_file
302
+
303
+ migration_file = migration_file.to_s
304
+ content = <<~RUBY
305
+ class AddRubyCmsFieldsToAhoyEvents < ActiveRecord::Migration[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]
306
+ def change
307
+ return unless table_exists?(:ahoy_events)
308
+
309
+ add_column :ahoy_events, :page_name, :string unless column_exists?(:ahoy_events, :page_name)
310
+ add_column :ahoy_events, :ip_address, :string unless column_exists?(:ahoy_events, :ip_address)
311
+ add_column :ahoy_events, :request_path, :string unless column_exists?(:ahoy_events, :request_path)
312
+ add_column :ahoy_events, :user_agent, :text unless column_exists?(:ahoy_events, :user_agent)
313
+ add_column :ahoy_events, :description, :text unless column_exists?(:ahoy_events, :description)
314
+
315
+ add_index :ahoy_events, :page_name, if_not_exists: true
316
+ add_index :ahoy_events, :ip_address, if_not_exists: true
317
+ add_index :ahoy_events, :request_path, if_not_exists: true
318
+ add_index :ahoy_events, [:name, :page_name], if_not_exists: true
319
+ add_index :ahoy_events, [:name, :request_path], if_not_exists: true
320
+ end
321
+ end
322
+ RUBY
323
+ File.write(migration_file, content)
324
+ end
325
+
326
+ def configure_ahoy_server_side_only
327
+ ahoy_path = Rails.root.join("config/initializers/ahoy.rb")
328
+ content = if ahoy_path.exist?
329
+ File.read(ahoy_path)
330
+ else
331
+ <<~RUBY
332
+ # Configure Ahoy
333
+ RUBY
334
+ end
335
+
336
+ # Ensure Ahoy is loaded before any references (fixes NameError when
337
+ # ahoy_matey loads after initializers)
338
+ content = %(require "ahoy_matey"\n\n#{content}) unless content.include?('require "ahoy_matey"')
339
+
340
+ # Ensure a default Ahoy store class exists.
341
+ unless content.match?(/class\s+Ahoy::Store\s*<\s*Ahoy::DatabaseStore/)
342
+ content += <<~RUBY
343
+
344
+ class Ahoy::Store < Ahoy::DatabaseStore
345
+ end
346
+ RUBY
347
+ end
348
+
349
+ if content.include?("Ahoy.api = false")
350
+ File.write(ahoy_path, content)
351
+ return
352
+ end
353
+
354
+ append = "\n\n# RubyCMS: server-side tracking only (no JavaScript)\nAhoy.api = false\nAhoy.geocode = false\n"
355
+ File.write(ahoy_path, "#{content}#{append}")
356
+ end
357
+
358
+ def action_text_already_installed?(migrate_dir)
359
+ return true if active_storage_tables_exist? || action_text_tables_exist?
360
+
361
+ existing = Dir.glob(migrate_dir.join("*.rb").to_s).join("\n")
362
+ existing.include?("create_action_text_rich_texts") ||
363
+ existing.include?("create_active_storage_tables")
364
+ end
365
+
366
+ def active_storage_tables_exist?
367
+ return false unless defined?(ActiveRecord::Base) && ActiveRecord::Base.connected?
368
+
369
+ c = ActiveRecord::Base.connection
370
+ c.data_source_exists?("active_storage_blobs") &&
371
+ c.data_source_exists?("active_storage_attachments")
372
+ rescue ActiveRecord::ConnectionNotEstablished, ActiveRecord::NoDatabaseError
373
+ false
374
+ end
375
+
376
+ def action_text_tables_exist?
377
+ return false unless defined?(ActiveRecord::Base) && ActiveRecord::Base.connected?
378
+
379
+ ActiveRecord::Base.connection.data_source_exists?("action_text_rich_texts")
380
+ rescue ActiveRecord::ConnectionNotEstablished, ActiveRecord::NoDatabaseError
381
+ false
382
+ end
383
+ end
384
+
385
+ def install_tailwind
386
+ gemfile = Rails.root.join("Gemfile")
387
+ tailwind_css = detect_tailwind_entry_css_path
388
+
389
+ install_tailwind_if_needed(gemfile, tailwind_css)
390
+ configure_tailwind(tailwind_css)
391
+ rescue StandardError => e
392
+ say "⚠ Task tailwind: Could not install: #{e.message}. Add tailwindcss-rails manually.",
393
+ :yellow
394
+ end
395
+
396
+ no_tasks do
397
+ def detect_tailwind_entry_css_path
398
+ candidates = [
399
+ Rails.root.join("app/assets/tailwind/application.css"),
400
+ Rails.root.join("app/assets/stylesheets/application.tailwind.css"),
401
+ Rails.root.join("app/assets/stylesheets/tailwind.css")
402
+ ]
403
+ candidates.find(&:exist?) || candidates.first
404
+ end
405
+
406
+ def install_tailwind_if_needed(gemfile, tailwind_css)
407
+ return if File.exist?(tailwind_css)
408
+
409
+ if File.read(gemfile).include?("tailwindcss-rails")
410
+ say "ℹ Task tailwind: Tailwind CSS gem found; running tailwindcss:install.", :cyan
411
+ else
412
+ say "ℹ Task tailwind: Adding tailwindcss-rails for admin styling.", :cyan
413
+ run "bundle add tailwindcss-rails"
414
+ end
415
+ run "bin/rails tailwindcss:install"
416
+ say "✓ Task tailwind: Installed Tailwind CSS", :green
417
+ end
418
+
419
+ def configure_tailwind(tailwind_css)
420
+ add_ruby_cms_tailwind_source(tailwind_css)
421
+ add_ruby_cms_tailwind_content_paths
422
+ run "bin/rails tailwindcss:build" if File.exist?(tailwind_css)
423
+ # Importmap pins are provided by the engine via `ruby_cms/config/importmap.rb`.
424
+ add_importmap_pins
425
+ add_stimulus_registration
426
+ end
427
+ end
428
+
429
+ def install_ruby_ui
430
+ gemfile = Rails.root.join("Gemfile")
431
+ gemfile_content = File.read(gemfile)
432
+ return if ruby_ui_in_gemfile?(gemfile_content)
433
+
434
+ add_ruby_ui_gem
435
+ rescue StandardError => e
436
+ say "⚠ Task ruby_ui: Could not add: #{e.message}. " \
437
+ "Run 'bundle add ruby_ui --group development --require false' manually.",
438
+ :yellow
439
+ nil
440
+ end
441
+
442
+ def run_ruby_ui_install
443
+ gemfile = Rails.root.join("Gemfile")
444
+ gemfile_content = File.read(gemfile)
445
+ return unless ruby_ui_in_gemfile?(gemfile_content)
446
+ return if ruby_ui_already_installed?
447
+
448
+ install_ruby_ui_generator
449
+ end
450
+
451
+ no_tasks do
452
+ def ruby_ui_in_gemfile?(content)
453
+ content.include?("ruby_ui") || content.include?("rails_ui")
454
+ end
455
+
456
+ def add_ruby_ui_gem
457
+ say "ℹ Task ruby_ui: Adding ruby_ui to Gemfile.", :cyan
458
+ run "bundle add ruby_ui --group development --require false"
459
+ run "bundle install"
460
+ say "✓ Task ruby_ui: Added ruby_ui to Gemfile", :green
461
+ end
462
+
463
+ def ruby_ui_already_installed?
464
+ ruby_ui_initializer = Rails.root.join("config/initializers/ruby_ui.rb")
465
+ ruby_ui_base = Rails.root.join("app/components/ruby_ui/base.rb")
466
+ if File.exist?(ruby_ui_initializer) || File.exist?(ruby_ui_base)
467
+ say "ℹ Task ruby_ui:install: ruby_ui is already installed. Skipping.", :cyan
468
+ return true
469
+ end
470
+ false
471
+ end
472
+
473
+ def install_ruby_ui_generator
474
+ say "ℹ Task ruby_ui:install: Running ruby_ui:install generator.", :cyan
475
+ try_ruby_ui_install
476
+ rescue StandardError => e
477
+ say "⚠ Task ruby_ui:install: Could not find generator: #{e.message}. " \
478
+ "Run 'rails g ruby_ui:install' manually.",
479
+ :yellow
480
+ end
481
+
482
+ def try_ruby_ui_install
483
+ run "bin/rails generate ruby_ui:install"
484
+ say "✓ Task ruby_ui:install: Installed ruby_ui", :green
485
+ rescue StandardError
486
+ try_rails_ui_install
487
+ end
488
+
489
+ def try_rails_ui_install
490
+ say "ℹ Task ruby_ui:install: Using rails_ui:install (ruby_ui alias).", :cyan
491
+ run "bin/rails generate rails_ui:install"
492
+ say "✓ Task ruby_ui:install: Installed rails_ui", :green
493
+ end
494
+
495
+ def add_ruby_ui_to_application_helper
496
+ helper_path = Rails.root.join("app/helpers/application_helper.rb")
497
+ return unless File.exist?(helper_path)
498
+
499
+ content = File.read(helper_path)
500
+ return if ruby_ui_already_included?(content)
501
+
502
+ inject_ruby_ui_include(helper_path)
503
+ rescue StandardError => e
504
+ say "⚠ Task ruby_ui/helper: Could not add RubyUI: #{e.message}. " \
505
+ "Add 'include RubyUI' manually.",
506
+ :yellow
507
+ end
508
+
509
+ def ruby_ui_already_included?(content)
510
+ content.include?("include RubyUI") || content.include?("include RubyUi")
511
+ end
512
+
513
+ def inject_ruby_ui_include(helper_path)
514
+ say "✓ Task ruby_ui/helper: Added include RubyUI to ApplicationHelper.", :green
515
+ inject_into_file helper_path.to_s, after: /module ApplicationHelper\n/ do
516
+ " include RubyUI\n"
517
+ end
518
+ end
519
+
520
+ def generate_ruby_ui_components
521
+ # Skip component generation - page builder removed.
522
+ # Generate manually: rails g ruby_ui:component ComponentName
523
+ say "ℹ Task ruby_ui/components: Skipping automatic component generation " \
524
+ "(page builder removed). " \
525
+ "Generate components manually if needed: rails g ruby_ui:component ComponentName",
526
+ :cyan
527
+ end
528
+ end
529
+
530
+ # Directories to skip when scanning for page templates
531
+ SKIP_VIEW_DIRS = %w[layouts shared mailers components admin].freeze
532
+
533
+ # NOTE: Rails generators are Thor groups; private methods can still be
534
+ # treated as "tasks" unless wrapped in `no_tasks`.
535
+ no_tasks do
536
+ def detect_page_templates
537
+ views_dir = Rails.root.join("app/views")
538
+ return {} unless Dir.exist?(views_dir)
539
+
540
+ pages = {}
541
+ views_base = views_dir.to_s
542
+ scan_for_templates(views_dir, pages, views_base)
543
+ log_detected_pages(pages) if pages.any?
544
+ pages
545
+ rescue StandardError => e
546
+ say "⚠ Task pages: Could not scan for page templates: #{e.message}.", :yellow
547
+ {}
548
+ end
549
+
550
+ def scan_for_templates(dir_path, pages, views_base, relative_path="")
551
+ # Find all template files in this directory
552
+ Dir.glob(File.join(dir_path, "*.{html.erb,html.haml,html.slim}")).each do |template_file|
553
+ base_name = File.basename(template_file, ".*")
554
+ base_name = File.basename(base_name, ".*") # Remove .html extension
555
+
556
+ # Skip partials
557
+ next if base_name.start_with?("_")
558
+ # Skip admin pages
559
+ next if relative_path.start_with?("admin") || relative_path == "admin"
560
+
561
+ # Build template path relative to app/views
562
+ if relative_path.empty?
563
+ template_path = base_name
564
+ page_key = base_name
565
+ elsif base_name == "index"
566
+ page_key = relative_path.split("/").last
567
+ template_path = "#{relative_path}/index"
568
+ # pages/index.html.erb -> "pages" => "pages/index"
569
+ else
570
+ # pages/home.html.erb -> "home" => "pages/home"
571
+ page_key = base_name
572
+ template_path = "#{relative_path}/#{base_name}"
573
+ end
574
+
575
+ pages[page_key] = template_path
576
+ end
577
+
578
+ # Recursively scan subdirectories (skip common non-page dirs, limit depth)
579
+ Dir.glob(File.join(dir_path, "*")).each do |path|
580
+ next unless File.directory?(path)
581
+
582
+ dir_name = File.basename(path)
583
+ # Skip admin directories and common non-page directories
584
+ next if SKIP_VIEW_DIRS.include?(dir_name)
585
+ # Skip if we're already in an admin path
586
+ next if relative_path.start_with?("admin")
587
+
588
+ # Limit depth to 2 levels (e.g., app/views/pages/home is OK, but not deeper)
589
+ depth = relative_path.empty? ? 1 : relative_path.split("/").length + 1
590
+ next if depth > 2
591
+
592
+ new_relative_path = relative_path.empty? ? dir_name : "#{relative_path}/#{dir_name}"
593
+ scan_for_templates(path, pages, views_base, new_relative_path)
594
+ end
595
+ end
596
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
597
+
598
+ def log_detected_pages(pages)
599
+ say "✓ Task pages: Detected #{pages.size} page template(s): " \
600
+ "#{pages.keys.join(', ')}", :green
601
+ end
602
+
603
+ def add_importmap_pins
604
+ importmap_path = Rails.root.join("config/importmap.rb")
605
+ return unless File.exist?(importmap_path)
606
+
607
+ content = File.read(importmap_path)
608
+ gem_js_path = calculate_gem_js_path
609
+ return if importmap_already_configured?(content, gem_js_path)
610
+
611
+ inject_importmap_pins(importmap_path, gem_js_path)
612
+ rescue StandardError => e
613
+ say "⚠ Task importmap: Could not add pins: #{e.message}. " \
614
+ "Add manually to config/importmap.rb.",
615
+ :yellow
616
+ end
617
+
618
+ def calculate_gem_js_path
619
+ RubyCms::Engine.root.join("app/javascript").relative_path_from(Rails.root).to_s
620
+ end
621
+
622
+ def importmap_already_configured?(content, gem_js_path)
623
+ pin_pattern = %(pin_all_from "#{gem_js_path}/controllers", under: "controllers")
624
+ content.include?("RubyCMS Stimulus controllers") || content.include?(pin_pattern)
625
+ end
626
+
627
+ def inject_importmap_pins(importmap_path, gem_js_path)
628
+ pin_line = %(pin_all_from "#{gem_js_path}/controllers", under: "controllers")
629
+ alias_line = %(pin "ruby_cms", to: "controllers/ruby_cms/index.js", preload: true)
630
+ inject_into_file importmap_path.to_s, before: /^end/ do
631
+ "\n # RubyCMS Stimulus controllers\n #{pin_line}\n #{alias_line}\n"
632
+ end
633
+ say "✓ Task importmap: Added RubyCMS controllers to importmap.rb.", :green
634
+ end
635
+
636
+ def add_stimulus_registration
637
+ controllers_app_path = Rails.root.join("app/javascript/controllers/application.js")
638
+ return handle_missing_stimulus_file unless File.exist?(controllers_app_path)
639
+
640
+ content = File.read(controllers_app_path)
641
+ unless stimulus_already_exposed?(content)
642
+ expose_stimulus_application(controllers_app_path,
643
+ content)
644
+ end
645
+ import_rubycms_controllers(controllers_app_path, content)
646
+ cleanup_old_registration_code
647
+ end
648
+
649
+ def handle_missing_stimulus_file
650
+ say "⚠ Task stimulus: Could not find app/javascript/controllers/application.js. " \
651
+ "RubyCMS controllers may need manual setup.",
652
+ :yellow
653
+ end
654
+
655
+ def stimulus_already_exposed?(content)
656
+ if content.include?("window.Stimulus") || content.include?("window.application")
657
+ say "ℹ Task stimulus: Stimulus application is already exposed on window.", :cyan
658
+ return true
659
+ end
660
+ false
661
+ end
662
+
663
+ def expose_stimulus_application(controllers_app_path, content)
664
+ return unless stimulus_application_startable?(content)
665
+
666
+ if stimulus_const_assignment?(content)
667
+ add_stimulus_const_pattern(controllers_app_path)
668
+ elsif stimulus_var_assignment?(content)
669
+ add_stimulus_var_pattern(controllers_app_path)
670
+ else
671
+ warn_manual_stimulus_exposure
672
+ end
673
+ end
674
+
675
+ def stimulus_application_startable?(content)
676
+ content.include?("const application") && content.include?("Application.start")
677
+ end
678
+
679
+ def stimulus_const_assignment?(content)
680
+ content.match?(/const\s+application\s*=\s*Application\.start\(\)/)
681
+ end
682
+
683
+ def stimulus_var_assignment?(content)
684
+ content.match?(/application\s*=\s*Application\.start\(\)/)
685
+ end
686
+
687
+ def warn_manual_stimulus_exposure
688
+ say "⚠ Task stimulus: Could not automatically expose. " \
689
+ "Manually add 'window.Stimulus = application'.",
690
+ :yellow
691
+ end
692
+
693
+ def add_stimulus_const_pattern(controllers_app_path)
694
+ gsub_file controllers_app_path.to_s,
695
+ /const\s+application\s*=\s*Application\.start\(\)/,
696
+ "const application = Application.start()\nwindow.Stimulus = application"
697
+ say_stimulus_added
698
+ end
699
+
700
+ def add_stimulus_var_pattern(controllers_app_path)
701
+ gsub_file controllers_app_path.to_s,
702
+ /application\s*=\s*Application\.start\(\)/,
703
+ "application = Application.start()\nwindow.Stimulus = application"
704
+ say_stimulus_added
705
+ end
706
+
707
+ def say_stimulus_added
708
+ say "✓ Task stimulus: Added window.Stimulus = application to " \
709
+ "controllers/application.js.",
710
+ :green
711
+ end
712
+
713
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
714
+ def import_rubycms_controllers(controllers_app_path, content)
715
+ # Re-read content in case it was modified by expose_stimulus_application
716
+ content = File.read(controllers_app_path) if File.exist?(controllers_app_path)
717
+
718
+ return if content.include?('import "ruby_cms"') || content.include?("import 'ruby_cms'")
719
+ return if content.include?("registerRubyCmsControllers")
720
+
721
+ # Try to add after the Stimulus import (most common pattern)
722
+ stimulus_import_pattern = %r{import\s+.*@hotwired/stimulus.*$}
723
+ if content.match?(stimulus_import_pattern)
724
+ inject_into_file controllers_app_path.to_s,
725
+ after: stimulus_import_pattern,
726
+ verbose: false do
727
+ "\nimport \"ruby_cms\""
728
+ end
729
+ say "✓ Task stimulus: Added RubyCMS controllers import.", :green
730
+ return
731
+ end
732
+
733
+ # Try to add after any import statement
734
+ first_import_pattern = /^import\s+.*$/m
735
+ if content.match?(first_import_pattern)
736
+ inject_into_file controllers_app_path.to_s,
737
+ after: first_import_pattern,
738
+ verbose: false do
739
+ "\nimport \"ruby_cms\""
740
+ end
741
+ say "✓ Task stimulus: Added RubyCMS controllers import.", :green
742
+ return
743
+ end
744
+
745
+ # Add at the very top if no imports found
746
+ prepend_to_file controllers_app_path.to_s, "import \"ruby_cms\"\n"
747
+ say "✓ Task stimulus: Added RubyCMS controllers import.", :green
748
+ rescue StandardError => e
749
+ say "⚠ Task stimulus: Could not add RubyCMS import: #{e.message}. " \
750
+ "Add 'import \"ruby_cms\"' manually to controllers/application.js.",
751
+ :yellow
752
+ end
753
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
754
+
755
+ def cleanup_old_registration_code
756
+ js_files = find_js_files_to_check
757
+ js_files.each {|js_file| cleanup_file_registration(js_file) }
758
+ rescue StandardError
759
+ # Silent fail - not critical
760
+ end
761
+
762
+ def find_js_files_to_check
763
+ [
764
+ Rails.root.join("app/javascript/application.js"),
765
+ Rails.root.join("app/javascript/index.js"),
766
+ Rails.root.join("app/javascript/entrypoints/application.js")
767
+ ].select(&:exist?)
768
+ end
769
+
770
+ def cleanup_file_registration(js_file)
771
+ content = File.read(js_file)
772
+ return unless needs_cleanup?(content)
773
+
774
+ new_content = remove_registration_lines(content)
775
+ return if new_content == content
776
+
777
+ File.write(js_file, new_content)
778
+ say "✓ Task stimulus: Removed old manual registration from #{js_file.basename}. " \
779
+ "Auto-registration handles this now.",
780
+ :green
781
+ end
782
+
783
+ def needs_cleanup?(content)
784
+ content.include?("registerRubyCmsControllers(application)") &&
785
+ content.exclude?("if (typeof application !== \"undefined\")")
786
+ end
787
+
788
+ def remove_registration_lines(content)
789
+ lines = content.split("\n")
790
+ lines.reject do |line|
791
+ line.strip.include?("registerRubyCmsControllers(application)") ||
792
+ (line.strip.start_with?("import") && line.include?("registerRubyCmsControllers") &&
793
+ line.include?("ruby_cms"))
794
+ end.join("\n")
795
+ end
796
+
797
+ # Helper: add @source for RubyCMS views/components so Tailwind finds utility classes.
798
+ # Not a generator task.
799
+ def add_ruby_cms_tailwind_source(tailwind_css_path)
800
+ return unless tailwind_css_path.to_s.present? && File.exist?(tailwind_css_path)
801
+
802
+ content = File.read(tailwind_css_path)
803
+ gem_source_lines = build_gem_source_lines(tailwind_css_path)
804
+ return if gem_source_lines.all? {|line| content.include?(line) }
805
+
806
+ inject_tailwind_source(tailwind_css_path, content, gem_source_lines)
807
+ rescue StandardError => e
808
+ say "⚠ Task tailwind/source: Could not add @source: #{e.message}. Add manually.", :yellow
809
+ end
810
+
811
+ # Tailwind v3 support (tailwind.config.js content array)
812
+ def add_ruby_cms_tailwind_content_paths # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
813
+ config_path = Rails.root.join("config/tailwind.config.js")
814
+ return unless File.exist?(config_path)
815
+
816
+ content = File.read(config_path)
817
+ patterns = ruby_cms_tailwind_content_patterns
818
+ return if patterns.all? {|p| content.include?(p) }
819
+
820
+ inject = "#{patterns.map {|p| " \"#{p}\"," }.join("\n")}\n"
821
+
822
+ # Insert inside `content: [` if present; otherwise no-op.
823
+ inserted = false
824
+ if content.match?(/content:\s*\[/)
825
+ gsub_file config_path.to_s, /content:\s*\[\s*\n/ do |match|
826
+ inserted = true
827
+ "#{match}#{inject}"
828
+ end
829
+ end
830
+
831
+ return unless inserted
832
+
833
+ say "✓ Task tailwind/content: Added RubyCMS paths to config/tailwind.config.js.", :green
834
+ rescue StandardError => e
835
+ say "⚠ Task tailwind/content: Could not update tailwind.config.js: #{e.message}.", :yellow
836
+ end
837
+
838
+ def ruby_cms_tailwind_content_patterns
839
+ views = RubyCms::Engine.root.join("app/views").relative_path_from(Rails.root).to_s
840
+ components = RubyCms::Engine.root.join("app/components").relative_path_from(Rails.root).to_s
841
+ [
842
+ "#{views}/**/*.erb",
843
+ "#{components}/**/*.rb"
844
+ ]
845
+ end
846
+
847
+ def build_gem_source_lines(tailwind_css_path)
848
+ css_dir = Pathname.new(tailwind_css_path).dirname
849
+ gem_views = path_relative_to_css_or_absolute(RubyCms::Engine.root.join("app/views"),
850
+ css_dir)
851
+ gem_components = path_relative_to_css_or_absolute(
852
+ RubyCms::Engine.root.join("app/components"), css_dir
853
+ )
854
+ [
855
+ %(@source "#{gem_views}/**/*.erb";),
856
+ %(@source "#{gem_components}/**/*.rb";)
857
+ ]
858
+ end
859
+
860
+ def path_relative_to_css_or_absolute(target_path, css_dir)
861
+ Pathname.new(target_path).relative_path_from(css_dir).to_s
862
+ rescue ArgumentError
863
+ # Different mount/volume: fall back to absolute path.
864
+ Pathname.new(target_path).to_s
865
+ end
866
+
867
+ def inject_tailwind_source(tailwind_css_path, content, gem_source_lines)
868
+ to_inject = build_tailwind_source_injection(gem_source_lines)
869
+ inserted = try_insert_after_patterns?(tailwind_css_path, content, to_inject)
870
+ inject_at_start(tailwind_css_path, to_inject) unless inserted
871
+ say "✓ Task tailwind/source: Added @source for RubyCMS views/components to " \
872
+ "tailwind/application.css.",
873
+ :green
874
+ end
875
+
876
+ def build_tailwind_source_injection(gem_source_lines)
877
+ to_inject = +"\n/* Include RubyCMS views/components so Tailwind finds utility classes. */\n"
878
+ Array(gem_source_lines).each {|line| to_inject << line << "\n" }
879
+ to_inject << "\n"
880
+ to_inject
881
+ end
882
+
883
+ def try_insert_after_patterns?(tailwind_css_path, content, to_inject)
884
+ patterns = [
885
+ %(@import "tailwindcss";\n),
886
+ %(@import "tailwindcss";),
887
+ %(@import "tailwindcss"\n),
888
+ %(@import "tailwindcss")
889
+ ]
890
+ patterns.each do |after_pattern|
891
+ next unless content.include?(after_pattern)
892
+
893
+ inject_into_file tailwind_css_path.to_s, after: after_pattern do
894
+ to_inject
895
+ end
896
+ return true
897
+ end
898
+ false
899
+ end
900
+
901
+ def inject_at_start(tailwind_css_path, to_inject)
902
+ inject_into_file tailwind_css_path.to_s, after: /\A/ do
903
+ to_inject
904
+ end
905
+ end
906
+ end
907
+
908
+ def run_migrate
909
+ say "ℹ Task db:migrate: Running db:migrate.", :cyan
910
+ success = run("bin/rails db:migrate")
911
+ raise "db:migrate failed" unless success
912
+
913
+ say "✓ Task db:migrate: Completed", :green
914
+ rescue StandardError => e
915
+ say "⚠ Task db:migrate: Failed: #{e.message}. Run rails db:create db:migrate if needed.",
916
+ :yellow
917
+ end
918
+
919
+ def run_seed_permissions
920
+ say "ℹ Task permissions: Seeding RubyCMS permissions.", :cyan
921
+ success = seed_permissions_via_open3
922
+ say_seed_permissions_outcome(success)
923
+ rescue StandardError => e
924
+ say_seed_permissions_error(e)
925
+ end
926
+
927
+ def run_setup_admin
928
+ return if skip_setup_admin_due_to_existing_admin?
929
+ return unless setup_admin_tty?
930
+
931
+ run_setup_admin_task
932
+ rescue StandardError => e
933
+ say_setup_admin_error(e)
934
+ end
935
+
936
+ no_tasks do
937
+ def seed_permissions_via_open3
938
+ require "open3"
939
+
940
+ _stdin, stdout, stderr, wait_thr = open3_seed_permissions_process
941
+ stderr_thread = stream_seed_permissions_stderr(stderr)
942
+ stdout_thread = stream_seed_permissions_stdout(stdout)
943
+
944
+ success = wait_thr.value.success?
945
+ stderr_thread.join
946
+ stdout_thread.join
947
+ close_seed_permissions_streams(stdout, stderr)
948
+ success
949
+ end
950
+
951
+ def open3_seed_permissions_process
952
+ Open3.popen3(*seed_permissions_command, chdir: Rails.root.to_s)
953
+ end
954
+
955
+ def seed_permissions_command
956
+ # Use argv form to avoid invoking a shell.
957
+ %w[bin/rails ruby_cms:seed_permissions ruby_cms:import_initializer_settings]
958
+ end
959
+
960
+ def stream_seed_permissions_stderr(stderr)
961
+ Thread.new do
962
+ stderr.each_line do |line|
963
+ $stderr.print line unless line.include?("already initialized constant")
964
+ end
965
+ end
966
+ end
967
+
968
+ def stream_seed_permissions_stdout(stdout)
969
+ Thread.new do
970
+ stdout.each_line {|line| $stdout.print line }
971
+ end
972
+ end
973
+
974
+ def close_seed_permissions_streams(stdout, stderr)
975
+ stdout.close
976
+ stderr.close
977
+ end
978
+
979
+ def say_seed_permissions_outcome(success)
980
+ if success
981
+ say "✓ Task permissions: Seeded RubyCMS permissions", :green
982
+ else
983
+ say "⚠ Task permissions: Could not seed. " \
984
+ "Run 'rails ruby_cms:seed_permissions' manually.",
985
+ :yellow
986
+ end
987
+ end
988
+
989
+ def say_seed_permissions_error(error)
990
+ say "⚠ Task permissions: Could not seed: #{error.message}. " \
991
+ "Run 'rails ruby_cms:seed_permissions' manually.",
992
+ :yellow
993
+ end
994
+
995
+ def skip_setup_admin_due_to_existing_admin?
996
+ return false unless user_with_admin_permissions_exists?
997
+
998
+ say "ℹ Task setup_admin: User with admin permissions already exists. " \
999
+ "Skipping setup_admin.",
1000
+ :cyan
1001
+ true
1002
+ end
1003
+
1004
+ def setup_admin_tty?
1005
+ return true if $stdin.tty?
1006
+
1007
+ say "ℹ Task setup_admin: Skipping interactive setup (non-TTY). " \
1008
+ "Run: rails ruby_cms:setup_admin",
1009
+ :yellow
1010
+ false
1011
+ end
1012
+
1013
+ def run_setup_admin_task
1014
+ say "ℹ Task setup_admin: Running setup_admin (create or pick first admin).", :cyan
1015
+ run "bin/rails ruby_cms:setup_admin"
1016
+ say "✓ Task setup_admin: Completed", :green
1017
+ end
1018
+
1019
+ def say_setup_admin_error(error)
1020
+ say "⚠ Task setup_admin: Failed or skipped: #{error.message}. " \
1021
+ "Run: rails ruby_cms:setup_admin",
1022
+ :yellow
1023
+ end
1024
+
1025
+ def user_with_admin_permissions_exists? # rubocop:disable Metrics/MethodLength
1026
+ return false unless defined?(::User)
1027
+
1028
+ begin
1029
+ # Ensure permissions exist first
1030
+ RubyCms::Permission.ensure_defaults!
1031
+
1032
+ # Check if any user has ALL required admin permissions
1033
+ required_keys = %w[
1034
+ manage_admin
1035
+ manage_permissions
1036
+ manage_content_blocks
1037
+ manage_visitor_errors
1038
+ ]
1039
+ required_permission_ids = RubyCms::Permission.where(key: required_keys).pluck(:id)
1040
+ return false if required_permission_ids.size != required_keys.size
1041
+
1042
+ # Find users who have all required permissions
1043
+ user_ids_with_all_perms = RubyCms::UserPermission
1044
+ .where(permission_id: required_permission_ids)
1045
+ .group(:user_id)
1046
+ .having("COUNT(DISTINCT permission_id) = ?", required_keys.size)
1047
+ .pluck(:user_id)
1048
+
1049
+ user_ids_with_all_perms.any?
1050
+ rescue StandardError
1051
+ # If there's an error (e.g., tables don't exist yet), assume no admin exists
1052
+ false
1053
+ end
1054
+ end
1055
+ end
1056
+
1057
+ def show_next_steps
1058
+ say NEXT_STEPS_MESSAGE, :green
1059
+ end
1060
+ end
1061
+ end
1062
+ end