senren-ui 0.1.5 → 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 (86) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +124 -2
  3. data/CONTRIBUTING.md +79 -9
  4. data/README.md +149 -14
  5. data/Rakefile +14 -1
  6. data/docs/components.md +222 -0
  7. data/docs/hot_reload.md +103 -0
  8. data/docs/performance_testing.md +38 -0
  9. data/lib/commands/senren/add/add_command.rb +35 -0
  10. data/lib/generators/senren/component/templates/controller.js.tt +7 -4
  11. data/lib/generators/senren/install/install_generator.rb +74 -0
  12. data/lib/generators/senren/install/templates/base_component.rb.tt +89 -8
  13. data/lib/generators/senren/install/templates/conventions.md.tt +26 -7
  14. data/lib/senren/rails/agent_rules_writer.rb +62 -19
  15. data/lib/senren/rails/asset_path_guard.rb +128 -0
  16. data/lib/senren/rails/base_component_patch.rb +64 -0
  17. data/lib/senren/rails/component_copier.rb +138 -18
  18. data/lib/senren/rails/component_installer.rb +73 -0
  19. data/lib/senren/rails/doctor.rb +7 -4
  20. data/lib/senren/rails/engine.rb +23 -0
  21. data/lib/senren/rails/host_paths.rb +11 -2
  22. data/lib/senren/rails/marker_block.rb +81 -0
  23. data/lib/senren/rails/registry.rb +73 -35
  24. data/lib/senren/rails/safe_write.rb +169 -0
  25. data/lib/senren/rails/skill_writer.rb +48 -12
  26. data/lib/senren/rails/version.rb +1 -1
  27. data/lib/senren/rails.rb +2 -1
  28. data/lib/senren-ui.rb +15 -0
  29. data/lib/tasks/senren.rake +53 -34
  30. data/registry/components.yml +45 -0
  31. data/registry/recipes.yml +12 -0
  32. data/templates/components/alert_dialog/alert_dialog_component.rb +1 -1
  33. data/templates/components/api_key_field/api_key_field_component.html.erb +1 -1
  34. data/templates/components/aspect_ratio/aspect_ratio_component.rb +7 -0
  35. data/templates/components/avatar/avatar_component.rb +8 -1
  36. data/templates/components/billing_plan_card/billing_plan_card_component.html.erb +1 -1
  37. data/templates/components/breadcrumb/breadcrumb_component.rb +2 -2
  38. data/templates/components/button/button_component.html.erb +1 -1
  39. data/templates/components/carousel/carousel_component.rb +1 -1
  40. data/templates/components/cart/cart_component.html.erb +61 -0
  41. data/templates/components/cart/cart_component.rb +71 -0
  42. data/templates/components/checkbox/checkbox_component.rb +1 -1
  43. data/templates/components/clipboard/clipboard_component.html.erb +1 -1
  44. data/templates/components/command/command_component.rb +2 -2
  45. data/templates/components/date_picker/date_picker_component.html.erb +1 -1
  46. data/templates/components/dialog/dialog_component.rb +1 -1
  47. data/templates/components/dropdown_menu/dropdown_menu_component.rb +10 -7
  48. data/templates/components/form/form_component.html.erb +8 -1
  49. data/templates/components/form/form_component.rb +12 -2
  50. data/templates/components/input/input_component.html.erb +1 -1
  51. data/templates/components/input/input_component.rb +19 -0
  52. data/templates/components/invite_member_dialog/invite_member_dialog_component.rb +1 -1
  53. data/templates/components/label/label_component.html.erb +1 -2
  54. data/templates/components/label/label_component.rb +12 -2
  55. data/templates/components/link/link_component.html.erb +1 -1
  56. data/templates/components/native_select/native_select_component.html.erb +19 -5
  57. data/templates/components/native_select/native_select_component.rb +17 -5
  58. data/templates/components/pagination/pagination_component.rb +2 -1
  59. data/templates/components/product_card/product_card_component.html.erb +38 -0
  60. data/templates/components/product_card/product_card_component.rb +49 -0
  61. data/templates/components/rich_text_editor_lite/rich_text_editor_lite_component.html.erb +1 -1
  62. data/templates/components/rich_text_editor_lite/rich_text_editor_lite_component.rb +1 -1
  63. data/templates/components/separator/separator_component.rb +7 -0
  64. data/templates/components/sheet/sheet_component.rb +1 -1
  65. data/templates/components/sidebar/sidebar_component.rb +2 -2
  66. data/templates/components/switch/switch_component.html.erb +2 -2
  67. data/templates/components/tooltip/tooltip_component.rb +2 -2
  68. data/templates/components/top_nav/top_nav_component.rb +2 -2
  69. data/templates/components/typography/typography_component.rb +7 -0
  70. data/templates/controllers/accordion_controller.js +1 -1
  71. data/templates/controllers/alert_dialog_controller.js +31 -7
  72. data/templates/controllers/cart_controller.js +83 -0
  73. data/templates/controllers/clipboard_controller.js +12 -1
  74. data/templates/controllers/command_controller.js +3 -4
  75. data/templates/controllers/context_menu_controller.js +38 -11
  76. data/templates/controllers/data_table_controller.js +8 -3
  77. data/templates/controllers/dialog_controller.js +47 -21
  78. data/templates/controllers/dropdown_menu_controller.js +40 -27
  79. data/templates/controllers/hover_card_controller.js +8 -0
  80. data/templates/controllers/invite_member_dialog_controller.js +6 -0
  81. data/templates/controllers/masked_input_controller.js +8 -1
  82. data/templates/controllers/popover_controller.js +25 -10
  83. data/templates/controllers/rich_text_editor_lite_controller.js +175 -28
  84. data/templates/controllers/sheet_controller.js +41 -11
  85. metadata +20 -6
  86. data/lib/senren/rails/installer.rb +0 -84
@@ -8,7 +8,9 @@ and obey it strictly.
8
8
  1. Use Senren components before writing custom HTML for the same purpose.
9
9
  2. Server-render via ViewComponent. Stimulus only for **local** behavior.
10
10
  3. Turbo handles server state. Controllers do not fetch/XHR from JS.
11
- 4. No React, Vue, Alpine, lit, or external state framework. Ever.
11
+ 4. Interactivity stays in Stimulus. These components render on the server, so
12
+ introducing a client-side framework alongside them puts two systems in
13
+ charge of the same state.
12
14
  5. Tailwind classes must use **semantic tokens** (`bg-background`,
13
15
  `text-foreground`, `bg-primary`, `text-muted-foreground`,
14
16
  `border-border`, `bg-destructive`). Do not hard-code `gray-*`,
@@ -20,11 +22,24 @@ and obey it strictly.
20
22
  block to `.new`, **not** to `render`, producing an empty component.
21
23
  Use either of these forms instead:
22
24
  ```erb
23
- <%= render(Senren::ButtonComponent.new(variant: :primary)) { "Save" } %>
25
+ <%%= render(Senren::ButtonComponent.new(variant: :primary)) { "Save" } %>
24
26
 
25
- <%= render Senren::ButtonComponent.new(variant: :primary) do %>
27
+ <%%= render Senren::ButtonComponent.new(variant: :primary) do %>
26
28
  Save
27
- <% end %>
29
+ <%% end %>
30
+ ```
31
+
32
+ 8. **Never put `app/components` on the asset load path.** With Propshaft this
33
+ publishes the directory: `assets:precompile` copies every component `.rb`
34
+ and `.html.erb` into `public/assets`, `.manifest.json` maps each logical
35
+ name to its digested filename, and the web server serves the source with
36
+ `HTTP 200` without Rails involved. Senren raises at boot in production if it
37
+ finds this, and warns elsewhere. Sidecar assets belong in their own
38
+ directory:
39
+ ```ruby
40
+ # config/initializers/assets.rb
41
+ Rails.application.config.assets.paths << Rails.root.join("app/components/assets")
42
+ # NOT app/components
28
43
  ```
29
44
 
30
45
  ## File ownership
@@ -47,9 +62,13 @@ and obey it strictly.
47
62
  ## Adding a component
48
63
 
49
64
  ```bash
50
- bin/rails senren:add <name> [<name>...]
51
- bin/rails senren:add dialog --no-client # override registry default
52
- bin/rails senren:add button --client # override registry default
65
+ bin/rails senren:add dialog
66
+ bin/rails senren:add button card badge
67
+ bin/rails senren:add dialog --no-client # override registry default
68
+ bundle exec rails senren:add button --client # equivalent alternate entry point
69
+
70
+ # Backward-compatible legacy task syntax:
71
+ bin/rails 'senren:add[button,card,badge]'
53
72
  ```
54
73
 
55
74
  After install you can edit any file under `app/components/senren/` directly.
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'yaml'
4
+ require 'senren/rails/marker_block'
5
+ require 'senren/rails/safe_write'
4
6
 
5
7
  module Senren
6
8
  module Rails
@@ -21,6 +23,7 @@ module Senren
21
23
  end
22
24
 
23
25
  def sync!
26
+ assert_distinct_adapters!
24
27
  paths.ensure_agent_dirs!
25
28
  files = []
26
29
  files << write_full_file(paths.agent_rules_file, render_source_rules)
@@ -31,8 +34,46 @@ module Senren
31
34
  files
32
35
  end
33
36
 
37
+ # Each adapter gets different content, so two of them resolving to the
38
+ # same file means the last write silently wins.
39
+ #
40
+ # `ln -s AGENTS.md CLAUDE.md` is a normal way to keep one set of agent
41
+ # instructions, and now that in-repo symlinks are allowed it reaches here
42
+ # rather than being refused as an escape.
43
+ #
44
+ # Public so ComponentInstaller can run it as a preflight. The first
45
+ # version of this check lived inside sync!, which runs after the copier,
46
+ # so `senren:add` failed with components already on disk and the ledger
47
+ # already written.
48
+ def assert_distinct_adapters!
49
+ collisions = adapter_targets.group_by { |path, _| SafeWrite.real_target(path) }
50
+ .select { |_, group| group.size > 1 }
51
+ return if collisions.empty?
52
+
53
+ raise ArgumentError, collision_message(collisions)
54
+ end
55
+
34
56
  private
35
57
 
58
+ def adapter_targets
59
+ {
60
+ paths.codex_agents_md => 'AGENTS.md',
61
+ paths.claude_md => 'CLAUDE.md',
62
+ paths.copilot_instructions => '.github/copilot-instructions.md',
63
+ paths.cursor_rule_file => '.cursor/rules/senren.mdc'
64
+ }
65
+ end
66
+
67
+ def collision_message(collisions)
68
+ detail = collisions.map do |target, group|
69
+ "#{group.map(&:last).join(' and ')} both resolve to #{target}"
70
+ end.join('; ')
71
+
72
+ 'Senren writes different instructions to each agent adapter, so these cannot share a file: ' \
73
+ "#{detail}. Replace the link with a real file, or have one adapter reference the other " \
74
+ 'by path instead of linking to it.'
75
+ end
76
+
36
77
  def installed_names
37
78
  path = paths.installed_components
38
79
  return [] unless path.exist?
@@ -57,8 +98,14 @@ module Senren
57
98
  - Use ViewComponent for reusable UI.
58
99
  - Use Turbo for server state.
59
100
  - Use Stimulus only for local behavior.
60
- - Do not introduce React, Vue, Alpine, or external state frameworks.
101
+ - Keep interactivity in Stimulus. These components render on the server,
102
+ so a client-side framework alongside them means two systems own the
103
+ same state.
61
104
  - Use semantic Tailwind tokens; do not hard-code color families.
105
+ - Never add `app/components` to `config.assets.paths`. Propshaft
106
+ publishes every file under an asset path, so component `.rb` and
107
+ `.html.erb` source is precompiled into `public/assets` and served
108
+ over HTTP. Put sidecar assets in `app/components/assets` instead.
62
109
 
63
110
  ## Important Files
64
111
 
@@ -82,7 +129,7 @@ module Senren
82
129
  - Prefer Senren components before custom HTML.
83
130
  - Keep reusable UI in ViewComponent.
84
131
  - Use Turbo for server state, Stimulus for local behavior.
85
- - Do not add React, Vue, Alpine, or external state frameworks.
132
+ - Keep interactivity in Stimulus rather than a client-side framework.
86
133
  - Use semantic Tailwind tokens.
87
134
  MD
88
135
  end
@@ -106,7 +153,7 @@ module Senren
106
153
  - Prefer Senren components before custom HTML.
107
154
  - Reusable UI must use ViewComponent.
108
155
  - Turbo handles server state; Stimulus handles local behavior.
109
- - Do not introduce React, Vue, Alpine, or external state frameworks.
156
+ - Keep interactivity in Stimulus rather than a client-side framework.
110
157
  - Use semantic Tailwind tokens.
111
158
  MD
112
159
  end
@@ -120,7 +167,7 @@ module Senren
120
167
  - Prefer Senren components before custom HTML.
121
168
  - Reusable UI uses ViewComponent.
122
169
  - Turbo for server state, Stimulus for local behavior.
123
- - No React/Vue/Alpine/external state frameworks.
170
+ - Keep interactivity in Stimulus rather than a client-side framework.
124
171
  - Use semantic Tailwind tokens.
125
172
  MD
126
173
  end
@@ -147,28 +194,24 @@ module Senren
147
194
 
148
195
  def write_adapter_file(path, generated, prefix: '')
149
196
  existing = path.exist? ? path.read : prefix.to_s
150
- updated = inject(existing, generated)
197
+ updated = inject(existing, generated, label: path.to_s)
151
198
  atomic_write(path, updated)
152
199
  path
153
200
  end
154
201
 
155
- def inject(existing, generated)
156
- if existing.include?(START_MARKER) && existing.include?(END_MARKER)
157
- before = existing.split(START_MARKER, 2).first
158
- tail = existing.split(START_MARKER, 2).last
159
- after = tail.split(END_MARKER, 2).last
160
- "#{before}#{START_MARKER}\n\n#{generated.rstrip}\n\n#{END_MARKER}#{after}"
161
- else
162
- body = existing.rstrip
163
- prefix = body.empty? ? '' : "#{body}\n\n"
164
- "#{prefix}#{START_MARKER}\n\n#{generated.rstrip}\n\n#{END_MARKER}\n"
165
- end
202
+ def inject(existing, generated, label: nil)
203
+ MarkerBlock.inject(
204
+ existing, generated,
205
+ start_marker: START_MARKER, end_marker: END_MARKER, label: label
206
+ )
166
207
  end
167
208
 
209
+ # write_adapter_file reads its destination before rewriting it, so a
210
+ # symlinked .senren, .github or .cursor/rules did not merely redirect the
211
+ # write — it pulled outside content in and wrote it back out. SafeWrite
212
+ # refuses on the real path before either half of that happens.
168
213
  def atomic_write(path, content)
169
- tmp = "#{path}.tmp"
170
- File.write(tmp, content)
171
- File.rename(tmp, path)
214
+ SafeWrite.write!(path, content, paths.root, path.to_s)
172
215
  end
173
216
  end
174
217
  end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Senren
4
+ module Rails
5
+ # Refuses to let component source be served as a static asset.
6
+ #
7
+ # ViewComponent documents putting sidecar assets next to components, and the
8
+ # usual way to reach them is:
9
+ #
10
+ # config.assets.paths << Rails.root.join("app/components")
11
+ #
12
+ # With Propshaft that one line makes every file under app/components a
13
+ # servable asset, `.rb` and `.html.erb` included. Verified against Propshaft
14
+ # 1.3.2: 129 component source files resolved, `assets:precompile` copied all
15
+ # of them into public/assets/, and public/assets/.manifest.json listed each
16
+ # logical path next to its digested filename — so the digest is not even an
17
+ # obstacle. In production the web server hands them out with Rails never
18
+ # involved.
19
+ #
20
+ # Development is a different risk calculation, so there it warns. Production
21
+ # raises: a boot failure is recoverable, published source is not.
22
+ module AssetPathGuard
23
+ SOURCE_EXTENSIONS = %w[.rb .erb].freeze
24
+
25
+ module_function
26
+
27
+ # `production:` is injected rather than read from ::Rails so the guard can
28
+ # be unit tested without booting Rails, which is how the rest of this
29
+ # library's unit suite runs.
30
+ def check!(app, io: $stderr, production: production_env?)
31
+ offenders = offending_paths(app)
32
+ return true if offenders.empty?
33
+
34
+ message = message_for(offenders)
35
+ raise message if production
36
+
37
+ io.puts("[senren] WARNING: #{message}")
38
+ false
39
+ end
40
+
41
+ # Fails closed. `Rails.env.production?` let a conventional
42
+ # RAILS_ENV=staging deploy print one line of stderr and precompile the
43
+ # source anyway, and the same held for review apps and any custom
44
+ # environment name. `local?` is true only for development and test — the
45
+ # two environments where exposure is acceptable — so everything else is
46
+ # treated as deployed. Available since Rails 7.1, which is the floor.
47
+ def production_env?
48
+ return false unless defined?(::Rails) && ::Rails.respond_to?(:env)
49
+ return !::Rails.env.local? if ::Rails.env.respond_to?(:local?)
50
+
51
+ !%w[development test].include?(::Rails.env.to_s)
52
+ end
53
+
54
+ # An asset path is only a problem when component source actually sits
55
+ # under it, so an app that keeps sidecar assets in their own directory is
56
+ # left alone.
57
+ def offending_paths(app)
58
+ components = components_dir(app)
59
+ return [] unless components&.directory?
60
+
61
+ asset_paths(app).select { |path| publishes_source?(path, components) }
62
+ end
63
+
64
+ # Whether serving this asset path would serve component source.
65
+ #
66
+ # `source_files?` used to be asked once about app/components as a whole,
67
+ # which is the wrong question for a descendant path: with that shape,
68
+ # app/components/assets holding nothing but CSS looked identical to
69
+ # app/components/senren holding every component. Only the overlapping
70
+ # subtree can publish anything, and that subtree is the deeper of the two
71
+ # paths.
72
+ def publishes_source?(asset_path, components)
73
+ asset = Pathname.new(asset_path.to_s).expand_path
74
+ components = components.expand_path
75
+ return false unless overlap?(asset, components)
76
+
77
+ source_files?(asset.to_s.length >= components.to_s.length ? asset : components)
78
+ end
79
+
80
+ def components_dir(app)
81
+ app.root.join('app/components')
82
+ rescue StandardError
83
+ nil
84
+ end
85
+
86
+ def source_files?(dir)
87
+ SOURCE_EXTENSIONS.any? { |ext| Dir.glob(dir.join("**/*#{ext}")).any? }
88
+ end
89
+
90
+ def asset_paths(app)
91
+ app.config.respond_to?(:assets) ? Array(app.config.assets.paths) : []
92
+ rescue StandardError
93
+ []
94
+ end
95
+
96
+ # Two paths overlap when one contains the other, in EITHER direction, and
97
+ # the test has to be separator-aware:
98
+ #
99
+ # app/components is an ancestor -> publishes everything
100
+ # app/components/senren is a descendant -> publishes the components
101
+ # app/comp shares a prefix -> publishes nothing
102
+ #
103
+ # The first version tested only the ancestor direction with a bare
104
+ # start_with?, so it missed the descendant case — which is what a
105
+ # developer writes after reading this module's own remediation text — and
106
+ # it blocked production boots over an unrelated app/comp directory.
107
+ def overlap?(one, other)
108
+ a = "#{one}#{File::SEPARATOR}"
109
+ b = "#{other}#{File::SEPARATOR}"
110
+
111
+ a.start_with?(b) || b.start_with?(a)
112
+ end
113
+
114
+ def message_for(offenders)
115
+ <<~MESSAGE.strip
116
+ app/components is on the asset load path (#{offenders.join(', ')}).
117
+
118
+ Propshaft serves every file under an asset path, so your component
119
+ .rb and .html.erb source would be published — assets:precompile copies
120
+ them into public/assets and .manifest.json lists them by name.
121
+
122
+ Move sidecar assets into their own directory and add that instead, for
123
+ example app/components/assets rather than app/components.
124
+ MESSAGE
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Senren
4
+ module Rails
5
+ # Ruby appended to a host app's existing BaseComponent when it predates the
6
+ # URL-aware component templates.
7
+ #
8
+ # This duplicates the helpers in
9
+ # lib/generators/senren/install/templates/base_component.rb.tt, because
10
+ # apps installed before those helpers existed never receive the template
11
+ # again. The two definitions are pinned together by
12
+ # test/security/component_url_security_test.rb: if they drift, migrated apps
13
+ # silently keep an older, weaker safe_url.
14
+ module BaseComponentPatch
15
+ # Single-quoted heredoc: the body is emitted verbatim, so the backslash
16
+ # escapes inside safe_url survive into the host app's file.
17
+ URL_HELPERS = <<~'RUBY'
18
+
19
+ # Added by senren:add for compatibility with URL-aware component templates.
20
+ require 'uri'
21
+
22
+ module Senren
23
+ class BaseComponent
24
+ SAFE_URL_PROTOCOLS = %w[http https mailto tel].freeze unless const_defined?(:SAFE_URL_PROTOCOLS)
25
+ SAFE_MEDIA_URL_PROTOCOLS = %w[http https].freeze unless const_defined?(:SAFE_MEDIA_URL_PROTOCOLS)
26
+
27
+ private
28
+
29
+ def safe_url(value, fallback: '#', protocols: SAFE_URL_PROTOCOLS)
30
+ url = value.to_s.strip
31
+ return fallback if url.empty?
32
+ # Browsers treat "\" as "/" for special schemes and strip TAB/CR/LF
33
+ # before parsing, so "/\evil.example" and "/<TAB>/evil.example" would
34
+ # both slip past a plain "//" check and resolve off-origin.
35
+ return fallback if url.include?('\\')
36
+ return fallback if url.match?(/[[:cntrl:]]/)
37
+ return url if url.start_with?('#')
38
+ # Any leading "//" is protocol-relative regardless of how many slashes
39
+ # follow. Rejecting here rather than relying on URI.parse matters:
40
+ # URI.parse("///evil.example") reports no scheme and no host, so the
41
+ # scheme-less fallback below would otherwise hand back a URL the browser
42
+ # resolves to https://evil.example/.
43
+ return fallback if url.start_with?('//')
44
+ return url if url.start_with?('/')
45
+
46
+ uri = URI.parse(url)
47
+ return url if uri.scheme && Array(protocols).map(&:to_s).include?(uri.scheme.downcase)
48
+ return fallback if uri.host
49
+ return url unless uri.scheme
50
+
51
+ fallback
52
+ rescue URI::InvalidURIError
53
+ fallback
54
+ end
55
+
56
+ def safe_media_url(value, fallback: nil)
57
+ safe_url(value, fallback: fallback, protocols: SAFE_MEDIA_URL_PROTOCOLS)
58
+ end
59
+ end
60
+ end
61
+ RUBY
62
+ end
63
+ end
64
+ end
@@ -1,14 +1,25 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'fileutils'
4
+ require 'pathname'
4
5
  require 'yaml'
5
6
  require 'time'
7
+ require 'senren/rails/base_component_patch'
8
+ require 'senren/rails/safe_write'
6
9
 
7
10
  module Senren
8
11
  module Rails
9
12
  # Copies component files from the gem's templates/ tree into the host
10
13
  # Rails app, and updates .senren/installed_components.yml.
11
14
  class ComponentCopier
15
+ class MissingTemplate < StandardError; end
16
+
17
+ INSTALL_GENERATOR_TEMPLATES = File.expand_path(
18
+ '../../generators/senren/install/templates', __dir__
19
+ ).freeze
20
+ BASE_COMPONENT_TEMPLATE = File.join(INSTALL_GENERATOR_TEMPLATES, 'base_component.rb.tt').freeze
21
+ BASE_URL_HELPER_PATCH = BaseComponentPatch::URL_HELPERS
22
+
12
23
  attr_reader :registry, :paths, :stdout
13
24
 
14
25
  def initialize(registry: Registry.load!, paths: HostPaths.new, stdout: $stdout)
@@ -21,19 +32,72 @@ module Senren
21
32
  # Returns the ordered list of component names actually installed.
22
33
  def install(component_names, client_override: nil, force: false)
23
34
  wanted = registry.dependencies(*component_names)
35
+ validate_client_override!(component_names, client_override)
24
36
  paths.ensure_dirs!
37
+ ensure_base_component_url_helpers!
25
38
 
39
+ requested = Array(component_names).map(&:to_s)
26
40
  wanted.each do |name|
27
41
  comp = registry.fetch(name)
28
- install_component(comp, client_override: client_override, force: force)
42
+ install_component(comp, client_override: override_for(name, requested, client_override), force: force)
29
43
  end
30
44
 
31
- update_installed_ledger(wanted, client_override: client_override)
45
+ update_installed_ledger(wanted, requested: requested, client_override: client_override)
32
46
  wanted
33
47
  end
34
48
 
35
49
  private
36
50
 
51
+ # --client / --no-client describes what the user asked for, not what its
52
+ # dependencies are. Applying it to the whole closure meant
53
+ # `senren:add context_menu --no-client` also suppressed the controller for
54
+ # dropdown_menu, whose markup emits data-controller unconditionally — so
55
+ # the installed menu silently never opened, and the ledger then recorded
56
+ # client: false for a component this command was never asked about.
57
+ #
58
+ # validate_client_override! already exempts dependencies in the other
59
+ # direction, for the same reason.
60
+ def override_for(name, requested, client_override)
61
+ requested.include?(name) ? client_override : nil
62
+ end
63
+
64
+ def ensure_base_component_url_helpers!
65
+ dest = paths.base_component_path
66
+
67
+ # Checked before #exist?, which follows the link, so a destination
68
+ # resolving outside the app root is refused before it is read.
69
+ return if refuse_symlink?(dest, 'base_component.rb')
70
+
71
+ if dest.exist?
72
+ return if base_component_has_url_helpers?
73
+
74
+ # Read-modify-write through SafeWrite rather than File.open(dest, 'a').
75
+ # The append was the one non-atomic write left in the gem: a process
76
+ # killed partway through left base_component.rb holding half a method,
77
+ # and an app that will not boot. This is the migration path onto the
78
+ # hardened URL helpers, so it runs on apps that already have code
79
+ # worth not corrupting.
80
+ SafeWrite.write!(dest, dest.read + BASE_URL_HELPER_PATCH, paths.root, 'base_component.rb')
81
+ stdout.puts " update #{dest} (url helpers)"
82
+ return
83
+ end
84
+
85
+ copy_file(BASE_COMPONENT_TEMPLATE, dest, force: false, label: 'base_component.rb')
86
+ end
87
+
88
+ # Delegates to SafeWrite so an intermediate symlinked directory is caught,
89
+ # not just a symlinked leaf. `app/components/senren -> /elsewhere` used to
90
+ # pass every check here.
91
+ def refuse_symlink?(dest, label)
92
+ SafeWrite.resolve(dest, paths.root, label, io: stdout).nil?
93
+ end
94
+
95
+ def base_component_has_url_helpers?
96
+ source = paths.base_component_path.read
97
+
98
+ source.include?('def safe_url') && source.include?('def safe_media_url')
99
+ end
100
+
37
101
  def install_component(comp, client_override:, force:)
38
102
  effective_client = effective_client_for(comp, client_override)
39
103
 
@@ -46,11 +110,28 @@ module Senren
46
110
  end
47
111
  end
48
112
 
113
+ # Only the explicitly requested components are checked: pulling in a
114
+ # dependency that has no controller must not fail the whole install.
115
+ def validate_client_override!(requested, override)
116
+ return unless override
117
+
118
+ offenders = Array(requested).map { |name| registry.fetch(name) }.reject { |comp| controller_file_for(comp) }
119
+ return if offenders.empty?
120
+
121
+ raise ArgumentError,
122
+ "--client was requested for #{offenders.map(&:name).join(', ')}, but the registry lists no Stimulus " \
123
+ 'controller for them. Drop --client, or add a controller file to the registry entry.'
124
+ end
125
+
126
+ def controller_file_for(comp)
127
+ comp.files.find { |relative| controller_source_path?(comp, relative) }
128
+ end
129
+
49
130
  def effective_client_for(comp, override)
50
- return comp.client? if override.nil?
51
- return false unless comp.can_have_client
131
+ desired = override.nil? ? comp.client? : (comp.can_have_client && override)
52
132
 
53
- override
133
+ # Never record client behavior in the ledger that was not installed.
134
+ desired && !controller_file_for(comp).nil?
54
135
  end
55
136
 
56
137
  def source_for(comp, relative)
@@ -59,32 +140,69 @@ module Senren
59
140
  # app/components/senren/<name>_component.html.erb -> templates/components/<name>/<name>_component.html.erb
60
141
  # app/javascript/controllers/senren/<name>_controller.js -> templates/controllers/<name>_controller.js
61
142
  base = File.basename(relative)
62
- if relative.include?('app/components/senren/')
143
+ if component_source_path?(comp, relative)
63
144
  File.join(Senren::Rails.templates_root, 'components', comp.name, base)
64
- elsif relative.include?('app/javascript/controllers/senren/')
145
+ elsif controller_source_path?(comp, relative)
65
146
  File.join(Senren::Rails.templates_root, 'controllers', base)
66
147
  else
67
148
  raise "ComponentCopier: do not know how to map #{relative.inspect}"
68
149
  end
69
150
  end
70
151
 
152
+ def component_source_path?(comp, relative)
153
+ [
154
+ "app/components/senren/#{comp.name}_component.rb",
155
+ "app/components/senren/#{comp.name}_component.html.erb"
156
+ ].include?(relative)
157
+ end
158
+
159
+ def controller_source_path?(comp, relative)
160
+ relative == "app/javascript/controllers/senren/#{comp.name}_controller.js"
161
+ end
162
+
71
163
  def copy_file(src, dest, force:, label:)
72
- unless File.exist?(src)
73
- stdout.puts " warn missing template: #{src} (#{label})"
74
- return
75
- end
164
+ raise MissingTemplate, "Missing component template: #{src} (#{label})" unless File.exist?(src)
165
+
166
+ dest = assert_inside_host_root!(dest, label)
167
+ return if refuse_symlink?(dest, label)
168
+
76
169
  if File.exist?(dest) && !force
77
170
  stdout.puts " skip #{dest} (already exists)"
78
171
  return
79
172
  end
80
- FileUtils.mkdir_p(File.dirname(dest))
81
- FileUtils.cp(src, dest)
173
+ SafeWrite.mkdir_p!(File.dirname(dest), paths.root, label)
174
+ SafeWrite.copy!(src, dest, paths.root, label)
82
175
  stdout.puts " copy #{dest}"
83
176
  end
84
177
 
85
- def update_installed_ledger(names, client_override:)
178
+ # Defense in depth: destinations are registry-derived, but a copier that
179
+ # can write anywhere is one bad registry entry away from a traversal.
180
+ #
181
+ # This used to compare expand_path, which normalises lexically and does
182
+ # not resolve symlinks, so a symlinked ancestor escaped it entirely.
183
+ def assert_inside_host_root!(dest, label)
184
+ SafeWrite.assert_inside!(dest, paths.root, label)
185
+ rescue SafeWrite::Escape => e
186
+ raise ArgumentError, e.message
187
+ end
188
+
189
+ # A ledger holding anything but a mapping used to reach `ledger['installed']
190
+ # ||= []` and raise `IndexError: string not matched` from String#[]=, which
191
+ # tells the user nothing about which file is wrong or why.
192
+ def load_ledger(path)
193
+ return {} unless path.exist?
194
+
195
+ content = YAML.safe_load_file(path) || {}
196
+ return content if content.is_a?(Hash)
197
+
198
+ raise ArgumentError,
199
+ "#{path} is not a Senren ledger: expected a YAML mapping, got #{content.class}. " \
200
+ 'Fix or delete the file and run the command again.'
201
+ end
202
+
203
+ def update_installed_ledger(names, requested:, client_override:)
86
204
  ledger_path = paths.installed_components
87
- ledger = ledger_path.exist? ? (YAML.safe_load_file(ledger_path) || {}) : {}
205
+ ledger = load_ledger(ledger_path)
88
206
  installed = ledger['installed'] ||= []
89
207
 
90
208
  names.each do |name|
@@ -93,7 +211,9 @@ module Senren
93
211
  'name' => name,
94
212
  'version' => Senren::Rails::VERSION,
95
213
  'installed_at' => Time.now.utc.iso8601,
96
- 'client' => effective_client_for(registry.fetch(name), client_override)
214
+ 'client' => effective_client_for(
215
+ registry.fetch(name), override_for(name, requested, client_override)
216
+ )
97
217
  }
98
218
  if existing
99
219
  existing.merge!(attrs.except('installed_at'))
@@ -103,8 +223,8 @@ module Senren
103
223
  end
104
224
 
105
225
  installed.sort_by! { |e| e['name'] }
106
- ledger_path.parent.mkpath
107
- File.write(ledger_path, YAML.dump(ledger))
226
+ SafeWrite.mkdir_p!(ledger_path.parent, paths.root, 'ledger')
227
+ SafeWrite.write!(ledger_path, YAML.dump(ledger), paths.root, 'ledger')
108
228
  end
109
229
  end
110
230
  end