Class: LcpRuby::Generators::InstallGenerator

Inherits:
Rails::Generators::Base
  • Object
show all
Includes:
FormatSupport
Defined in:
lib/generators/lcp_ruby/install_generator.rb

Constant Summary collapse

SAMPLE_HEADER =

Header injected into each sample file (DSL + YAML both use ‘#` for comments, so one literal works for both formats). DslToYaml strips in-source comments during conversion, so we prepend after copy via `prepend_sample_header!` rather than embedding in the template.

<<~HEADER
  # DELETE ME — sample scaffold for first-day discovery.
  # `lcp_ruby:install` (and `lcp new` without `--skip-sample`) seeds
  # this `item` entity so a fresh install has something to click at
  # /items. Once you've added your own entities via
  # `rails g lcp_ruby:entity`, delete all four sample files:
  #   config/lcp_ruby/models/item.{rb,yml}
  #   config/lcp_ruby/presenters/items.{rb,yml}
  #   config/lcp_ruby/permissions/item.yml
  #   config/lcp_ruby/views/items.{rb,yml}
  # ...and remove `- view_group: items` from config/lcp_ruby/menu.yml.

HEADER
[
  "//= link lcp_ruby/application.js",
  "//= link lcp_ruby/application.css",
  "//= link lcp_ruby/tom-select.css",
  "//= link lcp_ruby/tom-select.complete.min.js"
].freeze

Constants included from FormatSupport

FormatSupport::VALID_FORMATS

Instance Method Summary collapse

Methods included from FormatSupport

included, #validate_format

Instance Method Details

#add_autoloader_ignoreObject



75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
# File 'lib/generators/lcp_ruby/install_generator.rb', line 75

def add_autoloader_ignore
  app_file = "config/application.rb"
  full_path = File.join(destination_root, app_file)
  return unless File.exist?(full_path)

  content = File.read(full_path)
  return if content.include?("ignore_lcp_services")

  inject_into_file app_file, before: /^  end\nend\s*\z/ do
    <<~RUBY.indent(4)

      initializer "\#{Rails.application.class.module_parent_name.underscore}.ignore_lcp_services", before: :set_autoload_paths do
        %w[condition_services lcp_services actions event_handlers renderers].each do |dir|
          path = Rails.root.join("app", dir)
          Rails.autoloaders.main.ignore(path) if path.directory?
        end
      end
    RUBY
  end
end

#add_current_user_helperObject



321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
# File 'lib/generators/lcp_ruby/install_generator.rb', line 321

def add_current_user_helper
  controller_path = "app/controllers/application_controller.rb"
  full_path = File.join(destination_root, controller_path)
  return unless File.exist?(full_path)
  return if File.read(full_path).include?("current_user")

  inject_into_class controller_path, "ApplicationController" do
    <<~RUBY.indent(2)
      # TODO: Replace with real authentication
      # Run: rails generate lcp_ruby:install_auth
      def current_user
        @current_user ||= OpenStruct.new(id: 1, lcp_role: ["admin"], name: "Admin User")
      end
      helper_method :current_user

    RUBY
  end
end

#add_lcp_ruby_requireObject



64
65
66
67
68
69
70
71
72
73
# File 'lib/generators/lcp_ruby/install_generator.rb', line 64

def add_lcp_ruby_require
  app_file = "config/application.rb"
  full_path = File.join(destination_root, app_file)
  return unless File.exist?(full_path)
  return if File.read(full_path).include?('require "lcp_ruby"')

  inject_into_file app_file, after: /Bundler\.require\(\*Rails\.groups\)\n/ do
    "require \"lcp_ruby\"\n"
  end
end

#configure_skip_asset_pipelineObject

When the Sprockets compatibility shim is skipped, the engine’s boot check would otherwise raise LcpRuby::AssetPipelineError on the next non-generator boot. Write the matching opt-out so the documented ‘–skip-asset-pipeline` flag yields a bootable app.

MUST stay a separate step from ‘create_initializer` (which early-returns when the initializer already exists): on a re-run or install over a pre-existing initializer, the opt-out has to be applied regardless of whether the file was freshly created — otherwise the flag silently no-ops and the app won’t boot. Idempotent: skips when the line is already present.



280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
# File 'lib/generators/lcp_ruby/install_generator.rb', line 280

def configure_skip_asset_pipeline
  return unless options[:skip_asset_pipeline]

  initializer_path = "config/initializers/lcp_ruby.rb"
  full_path = File.join(destination_root, initializer_path)
  unless File.exist?(full_path)
    say_status :skip, "skip_asset_pipeline_check (no #{initializer_path})", :yellow
    return
  end

  content = File.read(full_path)
  return if content.include?("skip_asset_pipeline_check")

  # Match the configure block (both `do |x|` and brace `{ |x|` forms, the
  # latter is what the engine's AssetPipelineError message suggests) and
  # CAPTURE its block-variable name so the injected line targets the host's
  # actual var (`config`, `c`, …) — a hardcoded `config.` would NameError
  # at boot on a `|c|` initializer. Not anchored to a trailing newline, so
  # a `do |config| # comment` line still matches.
  match = content.match(/LcpRuby\.configure\s*(?:do|\{)\s*\|\s*(\w+)\s*\|/)
  unless match
    say_status :warn,
      "could not anchor skip_asset_pipeline_check in #{initializer_path}; " \
      "add `config.skip_asset_pipeline_check = true` inside the LcpRuby.configure block manually",
      :yellow
    return
  end

  var = match[1]
  optout = <<~RUBY.indent(2)
    # --skip-asset-pipeline was passed: the Sprockets compatibility shim
    # is NOT installed, so the boot-time asset-pipeline check is opted
    # out too (otherwise the engine raises LcpRuby::AssetPipelineError).
    # Only correct if you've wired your own JS bundler (esbuild,
    # importmap with a custom pin map, jsbundling, etc.).
    #{var}.skip_asset_pipeline_check = true
  RUBY

  inject_into_file initializer_path, "\n#{optout}", after: match[0]
end

#create_default_menuObject

Ships a ‘config/lcp_ruby/menu.yml` with the auto-populated `top_menu:` slot plus a fully-commented user-menu block. The block is opt-in: configurators uncomment after running `rails generate lcp_ruby:install_auth` (which wires Devise and the engine routes the block references). Without auth, the comments document the shape so the configurator discovers the capability when they need it.



169
170
171
# File 'lib/generators/lcp_ruby/install_generator.rb', line 169

def create_default_menu
  template "menu.yml.tt", "config/lcp_ruby/menu.yml"
end

#create_default_permissionsObject



158
159
160
# File 'lib/generators/lcp_ruby/install_generator.rb', line 158

def create_default_permissions
  template "default_permissions.yml", "config/lcp_ruby/permissions/_default.yml"
end

#create_directory_structureObject



118
119
120
121
122
# File 'lib/generators/lcp_ruby/install_generator.rb', line 118

def create_directory_structure
  %w[models presenters permissions views].each do |dir|
    empty_directory "config/lcp_ruby/#{dir}"
  end
end

#create_initializerObject



173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
# File 'lib/generators/lcp_ruby/install_generator.rb', line 173

def create_initializer
  initializer_path = "config/initializers/lcp_ruby.rb"
  full_path = File.join(destination_root, initializer_path)
  return if File.exist?(full_path)

  # Parser literal MUST stay byte-identical to app_template.rb
  # (drift test: locale_parser_drift_spec.rb). Can't share via require —
  # app_template.rb runs before Bundler loads the gem's lib/.
  # `.present?` (not `if options[:locale]`) — Thor passes empty --locale=
  # through as truthy "", which would otherwise yield broken locales: [].
  i18n_check_locales = if options[:locale].present?
    options[:locale].split(",").map(&:strip).reject(&:empty?).uniq.map(&:to_sym)
  else
    [ :en ]
  end

  create_file initializer_path, <<~RUBY
    # frozen_string_literal: true

    LcpRuby.configure do |config|
      # Authentication mode: :none, :built_in, or :external
      #   :none      — no auth required; ApplicationController exposes a
      #                stub current_user with role "admin" (see the
      #                ApplicationController file). Suitable for prototypes.
      #   :built_in  — set automatically by `rails generate lcp_ruby:install_auth`
      #                (Devise-based; flips this line and removes the stub).
      #   :external  — host app provides current_user (Devise, Sorcery, custom).
      config.authentication = :none

      # User class name (default: "User")
      # config.user_class = "User"

      # Role method on user object (default: :lcp_role)
      # config.role_method = :lcp_role

      # Where users land after hitting "/" (defaults to first menu item alphabetically).
      #   String form:  config.landing_page = "dashboard"
      #   Per-role:     config.landing_page = { "admin" => "admin-home", "default" => "dashboard" }
      # See docs/reference/engine-configuration.md#landing_page
      # config.landing_page = nil

      # Type-driven renderer defaults: when true, presenter columns
      # whose `renderer:` is absent get the type-driven default at
      # runtime (e.g. :boolean → boolean_icon, :enum → badge,
      # :text → truncate). Decision 14 in
      # docs/design/type_system_defaults.md. New apps default this to
      # true; existing apps upgrading should flip after auditing
      # their hand-written index columns.
      config.runtime_type_renderers = true

      # i18n_check (rake lcp_ruby:i18n_check) — boot-time lint that
      # asserts every labeled metadata element has a translation
      # in every locale below. See docs/reference/i18n_check.md.
      #
      # Locales: list ONLY the locales this app actually ships.
      # Without this, the lint walks I18n.available_locales which on
      # rails-i18n hosts is ~30 entries and produces unreadable
      # `missing in: en,ar,de,...` reports.
      #
      # Three offense kinds, each with its own severity:
      #   * literal_in_dsl       — hardcoded label paired with an
      #                            i18n key that's missing in some
      #                            locale. Real broken-UX bug → :error.
      #   * direct_render_label  — hardcoded label on a surface the
      #                            runtime renders directly without
      #                            an i18n lookup (column / filter /
      #                            view-group view labels, …). No
      #                            quick fix today → :warning.
      #   * missing_translation  — humanize-default fallback would
      #                            render in some locale → :warning
      #                            so the initial cleanup ramp
      #                            doesn't freeze every PR.
      config.i18n_check = {
        locales: #{i18n_check_locales.inspect},
        severity_per_kind: {
          literal_in_dsl:       :error,
          direct_render_label:  :warning,
          missing_translation:  :warning
        }
      }
    end

    # Auto-discover custom actions, event handlers, and condition services
    # from app/actions/, app/event_handlers/, and app/condition_services/.
    # `on_models_loaded` re-fires on every `LcpRuby.reload!` (autoreload
    # in dev), so the registries stay populated after metadata reloads;
    # `Rails.application.config.after_initialize` would only run once
    # per process boot.
    LcpRuby.on_models_loaded do
      app_path = Rails.root.join("app")
      LcpRuby::Actions::ActionRegistry.discover!(app_path.to_s)
      LcpRuby::Events::HandlerRegistry.discover!(app_path.to_s)
    end
  RUBY
end

#create_sample_modelObject



124
125
126
127
128
129
130
131
# File 'lib/generators/lcp_ruby/install_generator.rb', line 124

def create_sample_model
  return if options[:skip_sample]

  copy_dsl_or_yaml "model.rb",
    dsl_target: "config/lcp_ruby/models/item.rb",
    yaml_target: "config/lcp_ruby/models/item.yml"
  prepend_sample_header!(yaml_format? ? "config/lcp_ruby/models/item.yml" : "config/lcp_ruby/models/item.rb")
end

#create_sample_permissionsObject



142
143
144
145
146
147
# File 'lib/generators/lcp_ruby/install_generator.rb', line 142

def create_sample_permissions
  return if options[:skip_sample]

  template "permissions.yml", "config/lcp_ruby/permissions/item.yml"
  prepend_sample_header!("config/lcp_ruby/permissions/item.yml")
end

#create_sample_presenterObject



133
134
135
136
137
138
139
140
# File 'lib/generators/lcp_ruby/install_generator.rb', line 133

def create_sample_presenter
  return if options[:skip_sample]

  copy_dsl_or_yaml "presenter.rb",
    dsl_target: "config/lcp_ruby/presenters/items.rb",
    yaml_target: "config/lcp_ruby/presenters/items.yml"
  prepend_sample_header!(yaml_format? ? "config/lcp_ruby/presenters/items.yml" : "config/lcp_ruby/presenters/items.rb")
end

#create_sample_view_groupObject



149
150
151
152
153
154
155
156
# File 'lib/generators/lcp_ruby/install_generator.rb', line 149

def create_sample_view_group
  return if options[:skip_sample]

  copy_dsl_or_yaml "view_group.rb",
    dsl_target: "config/lcp_ruby/views/items.rb",
    yaml_target: "config/lcp_ruby/views/items.yml"
  prepend_sample_header!(yaml_format? ? "config/lcp_ruby/views/items.yml" : "config/lcp_ruby/views/items.rb")
end

#install_active_storageObject

Active Storage backs the ‘attachment` field type and `rich_text` content, both of which are core to LCP. Auto-invoke is idempotent: if Active Storage migrations already exist, the call is skipped.

Opt out with ‘–no-active-storage` if the host application uses external storage (e.g. a custom gem) or doesn’t need attachments at all.



346
347
348
349
350
351
352
353
354
355
356
# File 'lib/generators/lcp_ruby/install_generator.rb', line 346

def install_active_storage
  return unless options[:active_storage]

  if active_storage_already_installed?
    say_status :skip, "active_storage:install (migrations already present)"
    return
  end

  say_status :run, "active_storage:install"
  invoke_active_storage_install!
end

#install_asset_pipeline_compatObject

Sprockets compatibility shim (audit #16). Rails 8 ships Propshaft by default, which doesn’t understand the ‘//= require` directives LCP’s JS bundle uses. Without this shim, the layout’s ‘<script>` tags either don’t render (the old ‘respond_to?(:assets)` gate) or render but 404 — both present as “top nav invisible, no JS errors, no console output.” The engine’s boot check (‘check_asset_pipeline_compat!`) raises if Propshaft is loaded without sprockets-rails; this method is the one-shot fix for that error.

Idempotent: skips Gemfile insertion if the gem line already exists; appends only missing link directives to an existing manifest.js.



111
112
113
114
115
116
# File 'lib/generators/lcp_ruby/install_generator.rb', line 111

def install_asset_pipeline_compat
  return if options[:skip_asset_pipeline]

  add_sprockets_rails_to_gemfile!
  ensure_sprockets_manifest!
end

#mount_engineObject



96
97
98
# File 'lib/generators/lcp_ruby/install_generator.rb', line 96

def mount_engine
  route 'mount LcpRuby::Engine => "/"'
end

#setup_agent_affordancesObject

Agent affordances: copies the lcp-* skills and writes CLAUDE.md/AGENTS.md so AI agents (Claude Code, Codex, …) have orientation + authoring skills. Passive — no runtime impact. Idempotent via the managed-block strategy. Invoked in-process (not a ‘generate` subprocess) so it shares this run’s destination_root and stays robust in tests/host apps alike. Explicit empty args avoid bleeding install’s options into the sibling generator.



435
436
437
# File 'lib/generators/lcp_ruby/install_generator.rb', line 435

def setup_agent_affordances
  invoke "lcp_ruby:agent_setup", [], {}
end

#show_post_install_messageObject



439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
# File 'lib/generators/lcp_ruby/install_generator.rb', line 439

def show_post_install_message
  say ""
  say "LCP Ruby installed!", :green
  say ""
  unless options[:skip_sample]
    say "Sample files (each carries a 'DELETE ME' header — remove when you add your own entities):"
    say "  config/lcp_ruby/models/item.#{format_extension}"
    say "  config/lcp_ruby/presenters/items.#{format_extension}"
    say "  config/lcp_ruby/permissions/item.yml"
    say "  config/lcp_ruby/views/items.#{format_extension}"
    say ""
  end
  say "Tip: a commented user-menu block has been added to " \
      "config/lcp_ruby/menu.yml. Run `rails generate lcp_ruby:install_auth` " \
      "to auto-activate it (with Devise sign-out wired in), or " \
      "uncomment manually after wiring up your auth routes."
  say ""
  say "Next steps:"
  say "  1. rails db:prepare"
  say "  2. rails s"
  if options[:skip_sample]
    say "  3. Add your first entity: rails generate lcp_ruby:entity Foo name:string"
  else
    say "  3. Visit http://localhost:3000/items"
  end
  say ""
  say "Add features with generators:"
  say "  rails generate lcp_ruby:install_auth      # Devise authentication"
  say "  rails generate lcp_ruby:auditing          # Audit trail"
  say "  rails generate lcp_ruby:export            # Data export"
  say "  rails generate lcp_ruby:import            # Data import"
  say "  rails generate lcp_ruby:custom_fields     # Runtime field definitions"
  say "  rails generate lcp_ruby:saved_filters     # Saved search filters"
  say "  rails generate lcp_ruby:monitoring        # Error dashboard"
  say "  rails generate lcp_ruby:batch_operations  # Bulk operations"
  say "  rails generate lcp_ruby:background_jobs   # Job tracking"
  say "  rails generate lcp_ruby:pages             # DB-backed pages"
  say "  rails generate lcp_ruby:groups            # User groups"
  say "  rails generate lcp_ruby:role_model        # DB-backed roles"
  say "  rails generate lcp_ruby:permission_source # Dynamic permissions"
  say "  rails generate lcp_ruby:workflow_definition   # Workflows"
  say "  rails generate lcp_ruby:workflow_approvals    # Approval processes"
  say "  rails generate lcp_ruby:workflow_audit_log    # Workflow audit"
  say "  rails generate lcp_ruby:gapfree_sequences     # Sequential numbering"
  say ""
end