lcp 0.1.0 → 0.2.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 (109) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/lcp-custom-field/SKILL.md +10 -1
  3. data/.claude/skills/lcp-custom-field/agents/openai.yaml +4 -0
  4. data/.claude/skills/lcp-getting-started/SKILL.md +1 -1
  5. data/.claude/skills/lcp-getting-started/agents/openai.yaml +4 -0
  6. data/.claude/skills/lcp-host-binding/agents/openai.yaml +4 -0
  7. data/.claude/skills/lcp-model/agents/openai.yaml +4 -0
  8. data/.claude/skills/lcp-permissions/agents/openai.yaml +4 -0
  9. data/.claude/skills/lcp-presenter/agents/openai.yaml +4 -0
  10. data/.claude/skills/lcp-workflow/SKILL.md +10 -2
  11. data/.claude/skills/lcp-workflow/agents/openai.yaml +4 -0
  12. data/CHANGELOG.md +59 -1
  13. data/app/assets/javascripts/lcp_ruby/dev_toolbar.js +5 -6
  14. data/app/controllers/concerns/lcp_ruby/zone_resolution.rb +14 -1
  15. data/app/helpers/lcp_ruby/display/card_helper.rb +71 -16
  16. data/app/helpers/lcp_ruby/display_helper.rb +30 -3
  17. data/app/helpers/lcp_ruby/display_template_helper.rb +3 -3
  18. data/app/helpers/lcp_ruby/layout_helper.rb +49 -8
  19. data/app/helpers/lcp_ruby/tree_helper.rb +24 -3
  20. data/app/models/lcp_ruby/user.rb +10 -0
  21. data/app/views/layouts/lcp_ruby/application.html.erb +42 -20
  22. data/app/views/layouts/lcp_ruby/auth.html.erb +6 -1
  23. data/app/views/lcp_ruby/resources/_show_sections.html.erb +24 -17
  24. data/app/views/lcp_ruby/resources/_table_index.html.erb +12 -17
  25. data/app/views/lcp_ruby/widgets/_markdown.html.erb +13 -0
  26. data/app/views/lcp_ruby/widgets/_presenter_zone.html.erb +23 -7
  27. data/app/views/lcp_ruby/widgets/_rich_text.html.erb +25 -0
  28. data/docs/README.md +3 -0
  29. data/docs/feature-catalog.md +5 -2
  30. data/docs/feature-catalog.yml +89 -9
  31. data/docs/getting-started.md +38 -22
  32. data/docs/guides/dashboards.md +44 -1
  33. data/docs/guides/display-types.md +6 -0
  34. data/docs/guides/host-application.md +1 -1
  35. data/docs/guides/windows-setup.md +211 -0
  36. data/docs/guides/workflow.md +1 -1
  37. data/docs/reference/asset-pipeline.md +79 -0
  38. data/docs/reference/pages.md +50 -1
  39. data/docs/reference/presenters.md +6 -0
  40. data/docs/reference/workflow.md +2 -2
  41. data/examples/crm/Gemfile +3 -0
  42. data/examples/crm/Gemfile.lock +14 -9
  43. data/examples/crm/app/assets/config/manifest.js +4 -0
  44. data/examples/hr/Gemfile +3 -0
  45. data/examples/hr/Gemfile.lock +14 -9
  46. data/examples/hr/app/assets/config/manifest.js +4 -0
  47. data/examples/showcase/Gemfile +3 -0
  48. data/examples/showcase/Gemfile.lock +12 -9
  49. data/examples/showcase/app/assets/config/manifest.js +4 -0
  50. data/examples/showcase/config/lcp_ruby/pages/main_dashboard.yml +17 -2
  51. data/examples/showcase/config/locales/cs.yml +8 -0
  52. data/examples/showcase/config/locales/en.yml +8 -0
  53. data/examples/todo/Gemfile +3 -0
  54. data/examples/todo/Gemfile.lock +14 -9
  55. data/examples/todo/app/assets/config/manifest.js +4 -0
  56. data/exe/lcp +1 -1
  57. data/lib/generators/lcp_ruby/agent_setup_generator.rb +12 -9
  58. data/lib/generators/lcp_ruby/claude_skills_generator.rb +23 -19
  59. data/lib/generators/lcp_ruby/install_generator.rb +171 -3
  60. data/lib/generators/lcp_ruby/templates/agent_setup/agents_md.md +2 -1
  61. data/lib/generators/lcp_ruby/templates/agent_setup/claude_md.md +3 -2
  62. data/lib/generators/lcp_ruby/templates/install/menu.yml.tt +4 -0
  63. data/lib/generators/lcp_ruby/templates/monitoring/page.yml +5 -0
  64. data/lib/lcp_ruby/app_template.rb +37 -1
  65. data/lib/lcp_ruby/array_query.rb +33 -10
  66. data/lib/lcp_ruby/cli/new_command.rb +9 -4
  67. data/lib/lcp_ruby/cli/run_command.rb +98 -11
  68. data/lib/lcp_ruby/cli/skills_command.rb +27 -15
  69. data/lib/lcp_ruby/cli.rb +5 -1
  70. data/lib/lcp_ruby/configuration.rb +13 -2
  71. data/lib/lcp_ruby/custom_fields/applicator.rb +8 -0
  72. data/lib/lcp_ruby/custom_fields/query.rb +28 -20
  73. data/lib/lcp_ruby/display/markdown_sanitize.rb +36 -0
  74. data/lib/lcp_ruby/display/renderers/markdown.rb +6 -13
  75. data/lib/lcp_ruby/engine.rb +185 -1
  76. data/lib/lcp_ruby/export/data_generator.rb +5 -1
  77. data/lib/lcp_ruby/export/value_formatter.rb +34 -0
  78. data/lib/lcp_ruby/grouped_query/builder.rb +21 -0
  79. data/lib/lcp_ruby/metadata/configuration_validator.rb +5 -5
  80. data/lib/lcp_ruby/metadata/field_path.rb +57 -0
  81. data/lib/lcp_ruby/metadata/loader.rb +33 -1
  82. data/lib/lcp_ruby/metadata/menu_definition.rb +32 -2
  83. data/lib/lcp_ruby/metadata/menu_item.rb +53 -4
  84. data/lib/lcp_ruby/metadata/model_definition.rb +9 -0
  85. data/lib/lcp_ruby/metadata/zone_definition.rb +12 -5
  86. data/lib/lcp_ruby/metrics/json_query.rb +5 -0
  87. data/lib/lcp_ruby/model_factory/builder.rb +5 -0
  88. data/lib/lcp_ruby/model_factory/json_type_applicator.rb +35 -0
  89. data/lib/lcp_ruby/model_factory/label_method_builder.rb +3 -19
  90. data/lib/lcp_ruby/model_factory/schema_manager.rb +19 -0
  91. data/lib/lcp_ruby/pages/definition_validator.rb +13 -0
  92. data/lib/lcp_ruby/presenter/field_value_resolver.rb +36 -19
  93. data/lib/lcp_ruby/schemas/page.json +33 -1
  94. data/lib/lcp_ruby/search/custom_field_filter.rb +5 -0
  95. data/lib/lcp_ruby/skills_installer.rb +15 -2
  96. data/lib/lcp_ruby/tasks/doctor.rb +13 -8
  97. data/lib/lcp_ruby/version.rb +1 -1
  98. data/lib/lcp_ruby/widgets/data_resolver.rb +42 -1
  99. data/lib/lcp_ruby/widgets/date_grouper.rb +19 -0
  100. data/lib/lcp_ruby/workflow/contract_validator.rb +1 -1
  101. data/lib/lcp_ruby/workflow/model_source.rb +2 -2
  102. data/lib/lcp_ruby.rb +18 -0
  103. data/lib/tasks/lcp_ruby_db.rake +8 -1
  104. data/lib/tasks/lcp_ruby_i18n_check.rake +8 -0
  105. metadata +34 -24
  106. data/examples/crm/erd.md +0 -163
  107. data/examples/hr/erd.md +0 -396
  108. data/examples/showcase/erd.md +0 -567
  109. data/examples/todo/erd.md +0 -21
@@ -13,10 +13,21 @@ module LcpRuby
13
13
  # installed gem dir is typically read-only), so this copies the source,
14
14
  # rewrites the Gemfile to depend on the installed `lcp`, then bundles,
15
15
  # prepares the DB, and starts the server. Rails-free at invocation.
16
+ #
17
+ # Idempotent: the first run drops a {MARKER} dotfile in the target. A second
18
+ # `lcp run` on the same dir detects that marker and re-boots the existing app
19
+ # (skipping materialize + Gemfile rewrite, and using `db:prepare` so existing
20
+ # data survives) instead of aborting. A non-empty dir WITHOUT the marker is
21
+ # still refused — it's likely the user's own files, not our example.
16
22
  class RunCommand
17
23
  MINIMUM_RUBY_VERSION = "3.1"
18
24
  MINIMUM_RAILS_VERSION = "8.1"
19
25
 
26
+ # Dotfile written into a materialized app so a later `lcp run` recognizes
27
+ # it as ours and re-boots instead of refusing the non-empty dir. A dotfile
28
+ # so it never propagates via AssetCopier (its glob skips dotfiles).
29
+ MARKER = ".lcp-run"
30
+
20
31
  def initialize(name, dir, options, shell)
21
32
  @name = name.to_s.strip.empty? ? "showcase" : name.to_s.strip
22
33
  @dir = dir
@@ -26,8 +37,33 @@ module LcpRuby
26
37
 
27
38
  def run
28
39
  check_ruby_version!
29
- source = resolve_source!
30
40
  target = File.expand_path(@dir || "./#{@name}")
41
+
42
+ if materialized?(target)
43
+ reuse!(target)
44
+ else
45
+ materialize!(target)
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ # A dir we previously materialized — re-boot it, keeping its data.
52
+ def reuse!(target)
53
+ existing = File.read(File.join(target, MARKER)).strip
54
+ unless existing.empty? || existing == @name
55
+ say "Note: #{target} holds example '#{existing}' — re-booting that " \
56
+ "(ignoring requested '#{@name}').", :yellow
57
+ end
58
+
59
+ say "Reusing existing app in #{target} ...", :green
60
+ warn_global_rails
61
+ boot!(target, fresh: false)
62
+ end
63
+
64
+ # A fresh target — copy the source in, rewire it to the installed gem, boot.
65
+ def materialize!(target)
66
+ source = resolve_source!
31
67
  refuse_non_empty!(target)
32
68
 
33
69
  say "Materializing example '#{@name}' into #{target} ...", :green
@@ -36,10 +72,16 @@ module LcpRuby
36
72
  remove_stale_lockfile!(target)
37
73
 
38
74
  warn_global_rails
39
- boot!(target)
75
+ boot!(target, fresh: true)
40
76
  end
41
77
 
42
- private
78
+ def materialized?(target)
79
+ File.exist?(File.join(target, MARKER))
80
+ end
81
+
82
+ def write_marker!(target)
83
+ File.write(File.join(target, MARKER), "#{@name}\n")
84
+ end
43
85
 
44
86
  def resolve_source!
45
87
  path = File.join(LcpRuby::GemPaths.examples, @name)
@@ -99,21 +141,63 @@ module LcpRuby
99
141
  File.delete(lock) if File.exist?(lock)
100
142
  end
101
143
 
102
- def boot!(target)
144
+ def boot!(target, fresh:)
103
145
  Bundler.with_unbundled_env do
104
146
  run_step!("bundle install", target)
105
- # `db:setup` (not `db:prepare`): the target dir is guaranteed empty, and
106
- # db:setup invokes the `db:seed` Rake task *after* schema load so LCP's
107
- # `lcp_ruby:ensure_tables` prerequisite applies bind_to managed columns
108
- # before seeding. `db:prepare` calls `load_seed` directly, bypassing that
109
- # hook, and would crash seeds that touch boot-added managed columns.
110
- run_step!("bundle exec rails db:setup", target)
147
+ run_step!(db_command(target, fresh), target)
148
+ # Drop the marker only now: a marker means a `db:setup` completed at
149
+ # least once, so a later run can trust the reuse path. (run_step! aborts
150
+ # on failure, so reaching here implies the DB step succeeded.) Reuse
151
+ # doesn't rewrite it it already exists.
152
+ write_marker!(target) if fresh
111
153
  say ""
112
154
  say "Starting server (Ctrl-C to stop) ...", :green
113
155
  system("bundle", "exec", "rails", "server", chdir: target)
114
156
  end
115
157
  end
116
158
 
159
+ # Picks the Rails DB task, keeping the seed path `ensure_tables`-safe:
160
+ #
161
+ # * `db:setup` (not `db:prepare`) seeds via the `db:seed` *Rake task*, whose
162
+ # `lcp_ruby:ensure_tables` prerequisite applies bind_to managed columns
163
+ # before seeding. `db:prepare` seeds via `DatabaseTasks.load_seed`
164
+ # directly (see Rails' `prepare_all`), bypassing that hook — it would
165
+ # crash seeds that touch boot-added managed columns.
166
+ #
167
+ # So `db:prepare` is only safe when the DB already exists (it then just
168
+ # migrates and does NOT seed, preserving the user's data). We use it on
169
+ # reuse only when a DB is present; otherwise (fresh run, or a reuse whose
170
+ # DB was dropped/deleted) we fall back to the seed-safe `db:setup`.
171
+ def db_command(target, fresh)
172
+ return "bundle exec rails db:setup" if fresh || !database_present?(target)
173
+
174
+ "bundle exec rails db:prepare"
175
+ end
176
+
177
+ # Bundled examples (all the `lcp run` materializes) use sqlite3 under `db/`
178
+ # (crm/showcase/todo) or `storage/` (hr), and AssetCopier strips
179
+ # `*.sqlite3` on copy — so a DB file exists here only once `db:setup` has
180
+ # run. Its absence means "no DB yet / it was dropped", which routes reuse
181
+ # back to the seed-safe `db:setup`. Rails-free probe; no app boot (which
182
+ # would itself create tables).
183
+ #
184
+ # Scope to `db/`+`storage/` (not a whole-tree `**`): a bare `**/*.sqlite3`
185
+ # also matches stray DBs that don't mean "the app DB is here" — a gem's
186
+ # fixture under `vendor/bundle`, for instance — which would falsely report
187
+ # "present" and route a dropped-DB reuse to `db:prepare` (whose fresh-DB
188
+ # seed path crashes on bind_to managed columns). It also avoids walking a
189
+ # vendored bundle on every reuse.
190
+ #
191
+ # `base:` (not `File.join` into the pattern): `target` is an absolute path
192
+ # that may contain glob metacharacters (`[`, `{`, `*`) — e.g. a parent dir
193
+ # like `projects[old]`. Interpolating it into the pattern would make the
194
+ # glob silently match nothing, falsely report "no DB", and route a populated
195
+ # app to `db:setup`, which `schema:load`-purges the user's data. `base:`
196
+ # treats the directory literally.
197
+ def database_present?(target)
198
+ Dir.glob("{db,storage}/**/*.sqlite3", base: target).any?
199
+ end
200
+
117
201
  def run_step!(cmd, target)
118
202
  say ""
119
203
  say " → #{cmd}"
@@ -131,7 +215,10 @@ module LcpRuby
131
215
  # Non-fatal: the app bundles its own Rails, so a missing/old global `rails`
132
216
  # only warrants a heads-up, not a hard stop.
133
217
  def warn_global_rails
134
- version_output = `rails --version 2>/dev/null`.strip
218
+ # File::NULL (not a literal /dev/null) so the stderr redirect is valid
219
+ # on Windows cmd.exe too — a /dev/null redirect fails there and would
220
+ # garble this version probe.
221
+ version_output = `rails --version 2>#{File::NULL}`.strip
135
222
  return if version_output.empty?
136
223
 
137
224
  version = version_output.sub(/^Rails\s+/, "")
@@ -5,9 +5,9 @@ require_relative "../skills_installer"
5
5
 
6
6
  module LcpRuby
7
7
  module CLI
8
- # `lcp skills install --global|--local` — install the bundled `lcp-*` Claude
9
- # Code skills without a Rails app. The Rails-free sibling of the in-app
10
- # `lcp_ruby:claude_skills` generator. Refresh prunes removed `lcp-*` skills.
8
+ # `lcp skills install --global|--local` — install the bundled `lcp-*` AI
9
+ # authoring skills without a Rails app. Refresh prunes removed `lcp-*`
10
+ # skills per target.
11
11
  class SkillsCommand < Thor
12
12
  def self.exit_on_failure?
13
13
  true
@@ -15,36 +15,48 @@ module LcpRuby
15
15
 
16
16
  desc "install", "Install bundled lcp-* skills (requires --global or --local)"
17
17
  option :global, type: :boolean, default: false,
18
- desc: "Install into ~/.claude/skills/ (auto-triggers in every project)"
18
+ desc: "Install into per-agent global skills dirs (~/.claude/skills/, ~/.codex/skills/)"
19
19
  option :local, type: :boolean, default: false,
20
- desc: "Install into ./.claude/skills/ (this project only)"
20
+ desc: "Install into per-agent local skills dirs (./.claude/skills/, ./.codex/skills/)"
21
+ option :agent, type: :string, default: "all",
22
+ desc: "Target agent: all, claude, or codex"
21
23
  def install
22
- target = resolve_target
23
- result = LcpRuby::SkillsInstaller.new(target: target).call
24
- report(result, target)
24
+ resolve_targets.each do |agent, target|
25
+ result = LcpRuby::SkillsInstaller.new(target: target).call
26
+ report(result, agent, target)
27
+ end
25
28
  end
26
29
 
27
30
  private
28
31
 
29
32
  # Require an explicit, unambiguous destination — guessing would risk
30
33
  # writing to the wrong place (home vs project).
31
- def resolve_target
34
+ def resolve_targets
32
35
  if options[:global] == options[:local]
33
36
  raise Thor::Error,
34
- "Specify exactly one of --global (~/.claude/skills/) or --local (./.claude/skills/)."
37
+ "Specify exactly one of --global or --local."
35
38
  end
36
39
 
37
- base = options[:global] ? File.expand_path("~/.claude") : File.expand_path(".claude")
38
- File.join(base, "skills")
40
+ root = options[:global] ? File.expand_path("~") : File.expand_path(".")
41
+ LcpRuby::SkillsInstaller.agent_skill_targets(root: root, agents: selected_agents)
42
+ end
43
+
44
+ def selected_agents
45
+ raw = options[:agent].to_s.downcase
46
+ return LcpRuby::SkillsInstaller::DEFAULT_AGENTS if raw == "all"
47
+ return [ raw ] if LcpRuby::SkillsInstaller::AGENT_DIRECTORIES.key?(raw)
48
+
49
+ valid = LcpRuby::SkillsInstaller::DEFAULT_AGENTS.join(", ")
50
+ raise Thor::Error, "Unknown --agent=#{options[:agent].inspect}. Expected one of: all, #{valid}."
39
51
  end
40
52
 
41
- def report(result, target)
53
+ def report(result, agent, target)
42
54
  unless result.any_changes?
43
- say "No lcp-* skills found to install (checked #{LcpRuby::GemPaths.skills}).", :yellow
55
+ say "No #{agent} lcp-* skill changes for #{target} (checked #{LcpRuby::GemPaths.skills}).", :yellow
44
56
  return
45
57
  end
46
58
 
47
- say "Installed lcp-* skills into #{target}:", :green
59
+ say "Installed #{agent} lcp-* skills into #{target}:", :green
48
60
  result.created.each { |n| say " create #{n}" }
49
61
  result.updated.each { |n| say " update #{n}" }
50
62
  result.removed.each { |n| say " remove #{n} (no longer shipped)", :yellow }
data/lib/lcp_ruby/cli.rb CHANGED
@@ -33,6 +33,10 @@ module LcpRuby
33
33
  desc: "Skip interactive wizard, use defaults"
34
34
  option :skip_action_text, type: :boolean, default: false,
35
35
  desc: "Skip Action Text install (no WYSIWYG editor for :rich_text fields). Default: install."
36
+ option :skip_sample, type: :boolean, default: false,
37
+ desc: "Skip the Hello-World `item` model + presenter + permissions + view group. Default: create them (visible at /items). Generated files include DELETE ME headers."
38
+ option :skip_asset_pipeline, type: :boolean, default: false,
39
+ desc: "Skip the Sprockets compatibility shim (don't add `sprockets-rails` or create app/assets/config/manifest.js). Default: install. Only opt out if you've wired your own ESM bundler — LCP's JS won't load without one or the other."
36
40
  option :gem_source, type: :string,
37
41
  desc: "Gem source: 'rubygems' or 'path:/abs/path'"
38
42
  option :allow_partial, type: :boolean, default: false,
@@ -54,7 +58,7 @@ module LcpRuby
54
58
  end
55
59
  map "run" => :run_example
56
60
 
57
- desc "skills SUBCOMMAND", "Manage bundled lcp-* Claude Code skills"
61
+ desc "skills SUBCOMMAND", "Manage bundled lcp-* AI agent skills"
58
62
  subcommand "skills", SkillsCommand
59
63
 
60
64
  desc "docs SUBCOMMAND", "Locate or copy the bundled LCP reference docs"
@@ -16,7 +16,8 @@ module LcpRuby
16
16
  :theme,
17
17
  :i18n_check,
18
18
  :max_inheritance_depth,
19
- :runtime_type_renderers
19
+ :runtime_type_renderers,
20
+ :skip_asset_pipeline_check
20
21
 
21
22
  attr_reader :tenant_association_name
22
23
 
@@ -237,6 +238,16 @@ module LcpRuby
237
238
  # unflagged because their delta is strictly additive.
238
239
  @runtime_type_renderers = false
239
240
 
241
+ # Boot-time asset-pipeline compatibility check (see audit #16 and
242
+ # docs/reference/asset-pipeline.md). When false (default), boot
243
+ # raises `LcpRuby::AssetPipelineError` if Propshaft is loaded but
244
+ # sprockets-rails is not — the silent-failure mode that turned a
245
+ # broken nav-bar into hours of debugging. Set to true to opt out
246
+ # when you've manually wired an alternative bundler (esbuild,
247
+ # importmap-rails with custom pin map, etc.) and don't want the
248
+ # check to fire.
249
+ @skip_asset_pipeline_check = false
250
+
240
251
  # Role source defaults
241
252
  @role_source = :implicit
242
253
  @role_model = "role"
@@ -276,7 +287,7 @@ module LcpRuby
276
287
  @workflow_model = "workflow_definition"
277
288
  @workflow_model_fields = {
278
289
  name: "name",
279
- model_name: "model_name",
290
+ target_model: "target_model",
280
291
  field_name: "field_name",
281
292
  states: "states",
282
293
  transitions: "transitions",
@@ -23,6 +23,14 @@ module LcpRuby
23
23
  def install_custom_data_accessors!
24
24
  mn = model_definition.name
25
25
 
26
+ # MariaDB reflects JSON columns as `longtext`, so ActiveRecord never
27
+ # applies a JSON type to the dynamically-created custom_data column
28
+ # (PG `jsonb` and SQLite `json` are detected fine). Without an explicit
29
+ # type a Hash round-trips through Ruby's `Hash#to_s` (`{"k"=>"v"}`) and
30
+ # fails JSON.parse on read. Registering the type makes Hash<->JSON
31
+ # (de)serialization adapter-agnostic.
32
+ model_class.attribute :custom_data, :json, default: {}
33
+
26
34
  model_class.define_method(:read_custom_field) do |name|
27
35
  data = read_custom_data_hash
28
36
  data[name.to_s]
@@ -12,13 +12,9 @@ module LcpRuby
12
12
  def text_search_condition(table_name, field_name, query)
13
13
  validate_field_name!(field_name)
14
14
  conn = ActiveRecord::Base.connection
15
- quoted_table = conn.quote_table_name(table_name)
16
-
17
- if LcpRuby.postgresql?
18
- "#{quoted_table}.custom_data ->> #{conn.quote(field_name)} ILIKE #{conn.quote("%#{query}%")}"
19
- else
20
- "JSON_EXTRACT(#{quoted_table}.custom_data, #{conn.quote("$.#{field_name}")}) LIKE #{conn.quote("%#{query}%")}"
21
- end
15
+ expr = json_extract_expr(table_name, field_name)
16
+ op = LcpRuby.postgresql? ? "ILIKE" : "LIKE"
17
+ "#{expr} #{op} #{conn.quote("%#{query}%")}"
22
18
  end
23
19
 
24
20
  # Apply a text search on a scope for a custom field.
@@ -41,14 +37,7 @@ module LcpRuby
41
37
  def exact_match(scope, table_name, field_name, value)
42
38
  validate_field_name!(field_name)
43
39
  conn = ActiveRecord::Base.connection
44
- quoted_table = conn.quote_table_name(table_name)
45
-
46
- condition = if LcpRuby.postgresql?
47
- "#{quoted_table}.custom_data ->> #{conn.quote(field_name)} = #{conn.quote(value.to_s)}"
48
- else
49
- "JSON_EXTRACT(#{quoted_table}.custom_data, #{conn.quote("$.#{field_name}")}) = #{conn.quote(value.to_s)}"
50
- end
51
-
40
+ condition = "#{json_extract_expr(table_name, field_name)} = #{conn.quote(value.to_s)}"
52
41
  scope.where(Arel.sql(condition))
53
42
  end
54
43
 
@@ -74,12 +63,18 @@ module LcpRuby
74
63
  # @return [String] SQL expression fragment
75
64
  def json_extract_expr(table_name, field_name)
76
65
  conn = ActiveRecord::Base.connection
77
- quoted_table = conn.quote_table_name(table_name)
66
+ col = "#{conn.quote_table_name(table_name)}.custom_data"
78
67
 
79
68
  if LcpRuby.postgresql?
80
- "#{quoted_table}.custom_data ->> #{conn.quote(field_name)}"
69
+ "#{col} ->> #{conn.quote(field_name)}"
70
+ elsif LcpRuby.mysql?
71
+ # MySQL/MariaDB JSON_EXTRACT keeps the JSON quotes around scalars
72
+ # ("5", "text"), which breaks numeric CAST (-> 0) and LIKE matching.
73
+ # JSON_UNQUOTE strips them. SQLite's json_extract already returns the
74
+ # raw scalar and has no JSON_UNQUOTE function, so it keeps the bare form.
75
+ "JSON_UNQUOTE(JSON_EXTRACT(#{col}, #{conn.quote("$.#{field_name}")}))"
81
76
  else
82
- "JSON_EXTRACT(#{quoted_table}.custom_data, #{conn.quote("$.#{field_name}")})"
77
+ "JSON_EXTRACT(#{col}, #{conn.quote("$.#{field_name}")})"
83
78
  end
84
79
  end
85
80
 
@@ -97,9 +92,22 @@ module LcpRuby
97
92
  def apply_cast(expr, cast)
98
93
  case cast
99
94
  when :integer
100
- LcpRuby.postgresql? ? "(#{expr})::integer" : "CAST(#{expr} AS INTEGER)"
95
+ if LcpRuby.postgresql?
96
+ "(#{expr})::integer"
97
+ elsif LcpRuby.mysql?
98
+ # MySQL/MariaDB CAST has no INTEGER/REAL target — use SIGNED / DECIMAL.
99
+ "CAST(#{expr} AS SIGNED)"
100
+ else
101
+ "CAST(#{expr} AS INTEGER)"
102
+ end
101
103
  when :decimal, :float
102
- LcpRuby.postgresql? ? "(#{expr})::numeric" : "CAST(#{expr} AS REAL)"
104
+ if LcpRuby.postgresql?
105
+ "(#{expr})::numeric"
106
+ elsif LcpRuby.mysql?
107
+ "CAST(#{expr} AS DECIMAL(65, 30))"
108
+ else
109
+ "CAST(#{expr} AS REAL)"
110
+ end
103
111
  when :date
104
112
  LcpRuby.postgresql? ? "(#{expr})::date" : "DATE(#{expr})"
105
113
  else
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LcpRuby
4
+ module Display
5
+ # Sanitize allow-list shared between the model-level :markdown renderer
6
+ # (`Display::Renderers::Markdown`) and the page-level `markdown` widget
7
+ # partial (`app/views/lcp_ruby/widgets/_markdown.html.erb`). Both run
8
+ # the same Commonmarker → HTML pipeline and need the same downstream
9
+ # tag/attribute allow-list — defining the constants twice invited drift
10
+ # (a `<details>` tag added to one site but not the other would be a
11
+ # silent rendering inconsistency).
12
+ #
13
+ # `unsafe:` is deliberately *omitted* from the Commonmarker options
14
+ # below — raw HTML embedded in the markdown source is stripped at
15
+ # parse time before sanitize ever runs. This module is the
16
+ # second layer of defense; the parser is the first.
17
+ module MarkdownSanitize
18
+ TAGS = %w[
19
+ p br strong em del s a ul ol li blockquote pre code h1 h2 h3 h4 h5 h6
20
+ table thead tbody tfoot tr th td hr img input div span
21
+ ].freeze
22
+ # Note: `input` is kept for tasklist checkbox rendering (generated by
23
+ # the Commonmarker tasklist extension). Raw HTML `<input>` from user
24
+ # source is blocked by omitting `unsafe: true` from Commonmarker options.
25
+
26
+ ATTRIBUTES = %w[href src alt title class type checked disabled].freeze
27
+
28
+ # Commonmarker extension hash — same on both the renderer and the
29
+ # widget. Frozen so a host extension that wants to add e.g. footnotes
30
+ # creates a new hash rather than mutating the shared default.
31
+ COMMONMARKER_OPTIONS = {
32
+ extension: { table: true, tasklist: true, strikethrough: true, autolink: true }
33
+ }.freeze
34
+ end
35
+ end
36
+ end
@@ -1,26 +1,19 @@
1
1
  require "commonmarker"
2
+ require "lcp_ruby/display/markdown_sanitize"
2
3
 
3
4
  module LcpRuby
4
5
  module Display
5
6
  module Renderers
6
7
  class Markdown < BaseRenderer
7
- ALLOWED_TAGS = %w[
8
- p br strong em del s a ul ol li blockquote pre code h1 h2 h3 h4 h5 h6
9
- table thead tbody tfoot tr th td hr img input div span
10
- ].freeze
11
- # Note: `input` is kept for tasklist checkbox rendering (generated by Commonmarker tasklist extension).
12
- # Raw HTML `<input>` from user source is blocked by omitting `unsafe: true` from Commonmarker options.
13
-
14
- ALLOWED_ATTRIBUTES = %w[href src alt title class type checked disabled].freeze
15
-
16
8
  def render(value, options = {}, record: nil, view_context: nil)
17
9
  return nil if value.blank?
18
10
 
19
- html = Commonmarker.to_html(value.to_s, options: {
20
- extension: { table: true, tasklist: true, strikethrough: true, autolink: true }
21
- })
11
+ html = Commonmarker.to_html(value.to_s,
12
+ options: LcpRuby::Display::MarkdownSanitize::COMMONMARKER_OPTIONS)
22
13
  view_context.content_tag(:div,
23
- view_context.sanitize(html, tags: ALLOWED_TAGS, attributes: ALLOWED_ATTRIBUTES),
14
+ view_context.sanitize(html,
15
+ tags: LcpRuby::Display::MarkdownSanitize::TAGS,
16
+ attributes: LcpRuby::Display::MarkdownSanitize::ATTRIBUTES),
24
17
  class: "lcp-markdown")
25
18
  end
26
19
 
@@ -2,7 +2,6 @@ require "pundit"
2
2
  require "ransack"
3
3
  require "kaminari"
4
4
  require "positioning"
5
- require "view_component"
6
5
  require "turbo-rails"
7
6
  require "stimulus-rails"
8
7
  require "devise"
@@ -230,6 +229,189 @@ module LcpRuby
230
229
  app.config.assets.paths << root.join("vendor", "assets", "javascripts")
231
230
  app.config.assets.paths << root.join("vendor", "assets", "stylesheets")
232
231
  end
232
+
233
+ Engine.disable_sprockets_dev_cache_on_windows!(app)
234
+ end
235
+
236
+ # Windows-only dev-server immunisation against Sprockets' non-atomic
237
+ # cache write (from a 2026-05-29 Windows bug report). `Sprockets::PathUtils.
238
+ # atomic_write` writes the compiled-asset cache to a temp file then
239
+ # `File.rename`s it into place; on Windows that rename raises
240
+ # `Errno::EACCES` whenever the source/target handle is still held (the
241
+ # writer's own un-GC'd handle, Windows Search, antivirus, OneDrive).
242
+ # A stock app touches one or two assets on first load and rarely trips
243
+ # it; LCP declares 12+ precompile entries that all hit the cache in
244
+ # parallel on the very first request, so the rename race fires ~95% of
245
+ # the time — crashing the first page of every fresh `rails server`.
246
+ # Subsequent requests read the now-existing cache and skip the write,
247
+ # which is why "reload always fixes it".
248
+ #
249
+ # Swapping the file-backed assets cache for a null_store removes the
250
+ # rename entirely (slightly slower dev compilation, no race). Scoped
251
+ # as narrowly as the bug: Windows only (Unix keeps its cache),
252
+ # non-production only — development and test both live-compile assets
253
+ # on demand and race the same way; production precompiles once at
254
+ # deploy and never live-writes this cache, so it's skipped — and a
255
+ # no-op when Sprockets isn't loaded (a Propshaft-only host has no such
256
+ # cache). Hosts that want the cache back can re-set `env.cache` in
257
+ # their own `config.assets.configure`.
258
+ def self.disable_sprockets_dev_cache_on_windows!(app)
259
+ return unless sprockets_dev_cache_race_prone?
260
+ return unless app.config.respond_to?(:assets)
261
+
262
+ app.config.assets.configure do |env|
263
+ env.cache = ActiveSupport::Cache.lookup_store(:null_store)
264
+ end
265
+ end
266
+
267
+ # Extracted predicate so the platform/env gating is unit-testable
268
+ # without a Windows box — same pattern as `check_asset_pipeline_compat!`.
269
+ def self.sprockets_dev_cache_race_prone?
270
+ return false unless Gem.win_platform?
271
+ return false unless defined?(Rails) && Rails.respond_to?(:env)
272
+ return false if Rails.env.production?
273
+ return false unless defined?(Sprockets)
274
+
275
+ true
276
+ end
277
+
278
+ # Boot-time asset-pipeline compatibility check (audit #16). LCP's JS
279
+ # is glued together by Sprockets `//= require` directives in
280
+ # `app/assets/javascripts/lcp_ruby/application.js`. The layout's
281
+ # `<script>` tags render regardless of pipeline (the old
282
+ # `Rails.application.config.respond_to?(:assets)` gate is true on
283
+ # Propshaft too, so it never actually suppressed them); without
284
+ # sprockets-rails the bundle then 404s, and with sprockets-rails but
285
+ # no manifest.js it serves the raw `//= require` source — both present
286
+ # as "top nav invisible, no JS errors".
287
+ # Fail loud at boot instead. `after :load_config_initializers` so
288
+ # the host's `LcpRuby.configure { |c| c.skip_asset_pipeline_check = true }`
289
+ # opt-out is honored. `before "lcp_ruby.load_metadata"` so we fail
290
+ # before any expensive metadata work happens.
291
+ initializer "lcp_ruby.asset_pipeline_check",
292
+ after: :load_config_initializers,
293
+ before: "lcp_ruby.load_metadata" do
294
+ Engine.check_asset_pipeline_compat!
295
+ end
296
+
297
+ # Extracted as a class method so it can be unit-tested without
298
+ # booting Rails — same pattern as `register_oidc_bearer_resolver_if_enabled!`.
299
+ def self.check_asset_pipeline_compat!
300
+ # Skip during `rails generate lcp_ruby:install` (chicken-and-egg:
301
+ # the generator IS what adds sprockets-rails + manifest.js to the
302
+ # host). Any other generate context is also skipped — the user is
303
+ # likely in the middle of bootstrapping and the next `rails s` /
304
+ # `db:prepare` will catch it.
305
+ return if LcpRuby.generator_context?
306
+ return if LcpRuby.configuration.skip_asset_pipeline_check
307
+ # Failure mode A — sprockets-rails is NOT loaded: Propshaft alone can't
308
+ # serve the engine's `//= require` bundle.
309
+ raise asset_pipeline_error_no_sprockets unless defined?(Sprockets::Rails)
310
+
311
+ # Failure mode B — sprockets-rails IS loaded but app/assets/config/manifest.js
312
+ # doesn't link the engine bundle (or is missing): Sprockets serves the raw
313
+ # `//= require` stub instead of the compiled file, so the nav is silently
314
+ # inert. The error message already names this case — now we detect it
315
+ # instead of returning clean.
316
+ return if asset_manifest_links_engine_bundle?
317
+
318
+ raise asset_pipeline_error_no_manifest
319
+ end
320
+
321
+ # True when app/assets/config/manifest.js exists AND explicitly links the
322
+ # engine bundle (`//= link lcp_ruby/application...`, what `lcp_ruby:install`
323
+ # writes). A host's own `//= link_tree`/`link_directory` sweeps ITS asset
324
+ # dirs, never the gem's bundled assets — so a manifest that only sweeps
325
+ # `../images` or `../stylesheets` does NOT serve the engine JS. Requiring
326
+ # the explicit link is what actually catches failure mode B (sprockets-rails
327
+ # present, engine bundle unlinked → Sprockets serves the raw `//= require`
328
+ # stub, nav silently inert). Hosts that bundle the engine some other way set
329
+ # skip_asset_pipeline_check (short-circuited earlier). Missing manifest →
330
+ # false. A read error never blocks boot: degrade to "assume linked".
331
+ def self.asset_manifest_links_engine_bundle?
332
+ return true unless defined?(Rails) && Rails.respond_to?(:root) && Rails.root
333
+
334
+ manifest = Rails.root.join("app/assets/config/manifest.js")
335
+ return false unless File.exist?(manifest)
336
+
337
+ # Match an actual `//= link … lcp_ruby/application` directive at line start,
338
+ # not a bare substring — a commented-out `# //= link lcp_ruby/application`
339
+ # must not count as linked.
340
+ File.read(manifest).match?(%r{^\s*//=\s*link\b.*lcp_ruby/application})
341
+ rescue SystemCallError => e
342
+ raise unless Rails.env.production?
343
+ LcpRuby.record_error(e, subsystem: "asset_pipeline")
344
+ true
345
+ end
346
+
347
+ def self.asset_pipeline_error_no_sprockets
348
+ LcpRuby::AssetPipelineError.new(<<~MSG)
349
+ LCP Ruby requires a Sprockets-compatible asset pipeline, but the host
350
+ app appears to use Propshaft alone (Rails 8 default) without
351
+ sprockets-rails. The engine's JavaScript (~50 files glued by
352
+ `//= require` directives) cannot be served — the top navigation
353
+ bar and every Stimulus controller will be silently inert.
354
+
355
+ Fix (recommended, takes 30 seconds):
356
+
357
+ bin/rails generate lcp_ruby:install
358
+
359
+ which adds `gem "sprockets-rails"` to your Gemfile and creates
360
+ `app/assets/config/manifest.js` with the right link directives.
361
+ Then `bundle install` and restart.
362
+
363
+ Manual fix (equivalent):
364
+
365
+ # Gemfile
366
+ gem "sprockets-rails"
367
+
368
+ # app/assets/config/manifest.js
369
+ //= link lcp_ruby/application.js
370
+ //= link lcp_ruby/application.css
371
+ //= link lcp_ruby/tom-select.css
372
+ //= link lcp_ruby/tom-select.complete.min.js
373
+
374
+ Then `bundle install` and restart.
375
+
376
+ Opt out (only if you've wired your own ESM bundler):
377
+
378
+ # config/initializers/lcp_ruby.rb
379
+ LcpRuby.configure { |c| c.skip_asset_pipeline_check = true }
380
+
381
+ Full background: docs/reference/asset-pipeline.md
382
+ MSG
383
+ end
384
+
385
+ def self.asset_pipeline_error_no_manifest
386
+ LcpRuby::AssetPipelineError.new(<<~MSG)
387
+ LCP Ruby's JavaScript bundle is not being served. `sprockets-rails`
388
+ is installed, but `app/assets/config/manifest.js` does not link the
389
+ engine bundle — Sprockets then serves the raw `//= require` source
390
+ instead of the compiled file, so the top navigation bar and every
391
+ Stimulus controller are silently inert (no console error, no log).
392
+
393
+ Fix (recommended):
394
+
395
+ bin/rails generate lcp_ruby:install
396
+
397
+ which creates/updates `app/assets/config/manifest.js` with:
398
+
399
+ //= link lcp_ruby/application.js
400
+ //= link lcp_ruby/application.css
401
+ //= link lcp_ruby/tom-select.css
402
+ //= link lcp_ruby/tom-select.complete.min.js
403
+
404
+ Then restart. The engine bundle must be linked explicitly — a
405
+ `//= link_tree`/`//= link_directory` sweep of your own asset dirs does
406
+ NOT reach the gem's bundled assets.
407
+
408
+ Opt out (only if you've wired your own ESM bundler):
409
+
410
+ # config/initializers/lcp_ruby.rb
411
+ LcpRuby.configure { |c| c.skip_asset_pipeline_check = true }
412
+
413
+ Full background: docs/reference/asset-pipeline.md
414
+ MSG
233
415
  end
234
416
 
235
417
  BIND_TO_APPLICATOR_MAP = {
@@ -250,6 +432,8 @@ module LcpRuby
250
432
  "attachments" => ModelFactory::AttachmentApplicator,
251
433
  "enums" => ModelFactory::EnumApplicator,
252
434
  "service_accessors" => ModelFactory::ServiceAccessorApplicator,
435
+ "json_types" => ModelFactory::JsonTypeApplicator,
436
+ "array_types" => ModelFactory::ArrayTypeApplicator,
253
437
  "defaults" => ModelFactory::DefaultApplicator,
254
438
  "inherited_parent_validator" => ModelFactory::InheritedParentValidatorApplicator
255
439
  }.freeze