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.
- checksums.yaml +4 -4
- data/.claude/skills/lcp-custom-field/SKILL.md +10 -1
- data/.claude/skills/lcp-custom-field/agents/openai.yaml +4 -0
- data/.claude/skills/lcp-getting-started/SKILL.md +1 -1
- data/.claude/skills/lcp-getting-started/agents/openai.yaml +4 -0
- data/.claude/skills/lcp-host-binding/agents/openai.yaml +4 -0
- data/.claude/skills/lcp-model/agents/openai.yaml +4 -0
- data/.claude/skills/lcp-permissions/agents/openai.yaml +4 -0
- data/.claude/skills/lcp-presenter/agents/openai.yaml +4 -0
- data/.claude/skills/lcp-workflow/SKILL.md +10 -2
- data/.claude/skills/lcp-workflow/agents/openai.yaml +4 -0
- data/CHANGELOG.md +59 -1
- data/app/assets/javascripts/lcp_ruby/dev_toolbar.js +5 -6
- data/app/controllers/concerns/lcp_ruby/zone_resolution.rb +14 -1
- data/app/helpers/lcp_ruby/display/card_helper.rb +71 -16
- data/app/helpers/lcp_ruby/display_helper.rb +30 -3
- data/app/helpers/lcp_ruby/display_template_helper.rb +3 -3
- data/app/helpers/lcp_ruby/layout_helper.rb +49 -8
- data/app/helpers/lcp_ruby/tree_helper.rb +24 -3
- data/app/models/lcp_ruby/user.rb +10 -0
- data/app/views/layouts/lcp_ruby/application.html.erb +42 -20
- data/app/views/layouts/lcp_ruby/auth.html.erb +6 -1
- data/app/views/lcp_ruby/resources/_show_sections.html.erb +24 -17
- data/app/views/lcp_ruby/resources/_table_index.html.erb +12 -17
- data/app/views/lcp_ruby/widgets/_markdown.html.erb +13 -0
- data/app/views/lcp_ruby/widgets/_presenter_zone.html.erb +23 -7
- data/app/views/lcp_ruby/widgets/_rich_text.html.erb +25 -0
- data/docs/README.md +3 -0
- data/docs/feature-catalog.md +5 -2
- data/docs/feature-catalog.yml +89 -9
- data/docs/getting-started.md +38 -22
- data/docs/guides/dashboards.md +44 -1
- data/docs/guides/display-types.md +6 -0
- data/docs/guides/host-application.md +1 -1
- data/docs/guides/windows-setup.md +211 -0
- data/docs/guides/workflow.md +1 -1
- data/docs/reference/asset-pipeline.md +79 -0
- data/docs/reference/pages.md +50 -1
- data/docs/reference/presenters.md +6 -0
- data/docs/reference/workflow.md +2 -2
- data/examples/crm/Gemfile +3 -0
- data/examples/crm/Gemfile.lock +14 -9
- data/examples/crm/app/assets/config/manifest.js +4 -0
- data/examples/hr/Gemfile +3 -0
- data/examples/hr/Gemfile.lock +14 -9
- data/examples/hr/app/assets/config/manifest.js +4 -0
- data/examples/showcase/Gemfile +3 -0
- data/examples/showcase/Gemfile.lock +12 -9
- data/examples/showcase/app/assets/config/manifest.js +4 -0
- data/examples/showcase/config/lcp_ruby/pages/main_dashboard.yml +17 -2
- data/examples/showcase/config/locales/cs.yml +8 -0
- data/examples/showcase/config/locales/en.yml +8 -0
- data/examples/todo/Gemfile +3 -0
- data/examples/todo/Gemfile.lock +14 -9
- data/examples/todo/app/assets/config/manifest.js +4 -0
- data/exe/lcp +1 -1
- data/lib/generators/lcp_ruby/agent_setup_generator.rb +12 -9
- data/lib/generators/lcp_ruby/claude_skills_generator.rb +23 -19
- data/lib/generators/lcp_ruby/install_generator.rb +171 -3
- data/lib/generators/lcp_ruby/templates/agent_setup/agents_md.md +2 -1
- data/lib/generators/lcp_ruby/templates/agent_setup/claude_md.md +3 -2
- data/lib/generators/lcp_ruby/templates/install/menu.yml.tt +4 -0
- data/lib/generators/lcp_ruby/templates/monitoring/page.yml +5 -0
- data/lib/lcp_ruby/app_template.rb +37 -1
- data/lib/lcp_ruby/array_query.rb +33 -10
- data/lib/lcp_ruby/cli/new_command.rb +9 -4
- data/lib/lcp_ruby/cli/run_command.rb +98 -11
- data/lib/lcp_ruby/cli/skills_command.rb +27 -15
- data/lib/lcp_ruby/cli.rb +5 -1
- data/lib/lcp_ruby/configuration.rb +13 -2
- data/lib/lcp_ruby/custom_fields/applicator.rb +8 -0
- data/lib/lcp_ruby/custom_fields/query.rb +28 -20
- data/lib/lcp_ruby/display/markdown_sanitize.rb +36 -0
- data/lib/lcp_ruby/display/renderers/markdown.rb +6 -13
- data/lib/lcp_ruby/engine.rb +185 -1
- data/lib/lcp_ruby/export/data_generator.rb +5 -1
- data/lib/lcp_ruby/export/value_formatter.rb +34 -0
- data/lib/lcp_ruby/grouped_query/builder.rb +21 -0
- data/lib/lcp_ruby/metadata/configuration_validator.rb +5 -5
- data/lib/lcp_ruby/metadata/field_path.rb +57 -0
- data/lib/lcp_ruby/metadata/loader.rb +33 -1
- data/lib/lcp_ruby/metadata/menu_definition.rb +32 -2
- data/lib/lcp_ruby/metadata/menu_item.rb +53 -4
- data/lib/lcp_ruby/metadata/model_definition.rb +9 -0
- data/lib/lcp_ruby/metadata/zone_definition.rb +12 -5
- data/lib/lcp_ruby/metrics/json_query.rb +5 -0
- data/lib/lcp_ruby/model_factory/builder.rb +5 -0
- data/lib/lcp_ruby/model_factory/json_type_applicator.rb +35 -0
- data/lib/lcp_ruby/model_factory/label_method_builder.rb +3 -19
- data/lib/lcp_ruby/model_factory/schema_manager.rb +19 -0
- data/lib/lcp_ruby/pages/definition_validator.rb +13 -0
- data/lib/lcp_ruby/presenter/field_value_resolver.rb +36 -19
- data/lib/lcp_ruby/schemas/page.json +33 -1
- data/lib/lcp_ruby/search/custom_field_filter.rb +5 -0
- data/lib/lcp_ruby/skills_installer.rb +15 -2
- data/lib/lcp_ruby/tasks/doctor.rb +13 -8
- data/lib/lcp_ruby/version.rb +1 -1
- data/lib/lcp_ruby/widgets/data_resolver.rb +42 -1
- data/lib/lcp_ruby/widgets/date_grouper.rb +19 -0
- data/lib/lcp_ruby/workflow/contract_validator.rb +1 -1
- data/lib/lcp_ruby/workflow/model_source.rb +2 -2
- data/lib/lcp_ruby.rb +18 -0
- data/lib/tasks/lcp_ruby_db.rake +8 -1
- data/lib/tasks/lcp_ruby_i18n_check.rake +8 -0
- metadata +34 -24
- data/examples/crm/erd.md +0 -163
- data/examples/hr/erd.md +0 -396
- data/examples/showcase/erd.md +0 -567
- 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
|
-
|
|
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
|
-
|
|
106
|
-
#
|
|
107
|
-
#
|
|
108
|
-
#
|
|
109
|
-
#
|
|
110
|
-
|
|
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
|
-
|
|
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-*`
|
|
9
|
-
#
|
|
10
|
-
#
|
|
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/
|
|
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/
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
34
|
+
def resolve_targets
|
|
32
35
|
if options[:global] == options[:local]
|
|
33
36
|
raise Thor::Error,
|
|
34
|
-
"Specify exactly one of --global
|
|
37
|
+
"Specify exactly one of --global or --local."
|
|
35
38
|
end
|
|
36
39
|
|
|
37
|
-
|
|
38
|
-
|
|
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-*
|
|
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-*
|
|
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
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
-
|
|
66
|
+
col = "#{conn.quote_table_name(table_name)}.custom_data"
|
|
78
67
|
|
|
79
68
|
if LcpRuby.postgresql?
|
|
80
|
-
"#{
|
|
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(#{
|
|
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?
|
|
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?
|
|
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,
|
|
20
|
-
|
|
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,
|
|
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
|
|
data/lib/lcp_ruby/engine.rb
CHANGED
|
@@ -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
|