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
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'senren/rails/agent_rules_writer'
5
+ require 'senren/rails/component_copier'
6
+ require 'senren/rails/host_paths'
7
+ require 'senren/rails/registry'
8
+ require 'senren/rails/skill_writer'
9
+
10
+ module Senren
11
+ module Rails
12
+ # Installs one or more registered Senren components into the host app and
13
+ # refreshes the generated guidance files afterward.
14
+ class ComponentInstaller
15
+ USAGE = 'Usage: bin/rails senren:add NAME [NAME...] [--client | --no-client]'
16
+
17
+ attr_reader :registry, :paths, :stdout
18
+
19
+ def self.normalize_names(names)
20
+ Array(names)
21
+ .flatten
22
+ .flat_map { |entry| entry.to_s.split(/[,\s]+/) }
23
+ .reject { |entry| entry.empty? || entry.start_with?('-') }
24
+ .uniq
25
+ end
26
+
27
+ def initialize(registry: Registry.load!, paths: HostPaths.new, stdout: $stdout)
28
+ @registry = registry
29
+ @paths = paths
30
+ @stdout = stdout
31
+ end
32
+
33
+ def install(names:, client_override: nil, force: false)
34
+ normalized_names = self.class.normalize_names(names)
35
+ raise ArgumentError, USAGE if normalized_names.empty?
36
+
37
+ # Preflight. Everything below writes to disk, and a failure partway
38
+ # through leaves components copied, the ledger updated and the guidance
39
+ # files stale -- which is exactly what the first version of the adapter
40
+ # collision check did.
41
+ AgentRulesWriter.new(registry: registry, paths: paths).assert_distinct_adapters!
42
+
43
+ installed = ComponentCopier.new(registry: registry, paths: paths, stdout: stdout)
44
+ .install(normalized_names, client_override: client_override, force: force)
45
+
46
+ refresh_registry_mirror
47
+ SkillWriter.new(registry: registry, paths: paths).sync!
48
+ AgentRulesWriter.new(registry: registry, paths: paths).sync!
49
+
50
+ stdout.puts "Installed: #{installed.join(', ')}"
51
+ installed
52
+ end
53
+
54
+ private
55
+
56
+ # The mirror was written once at install time and never again, so it drifted
57
+ # from the gem on every upgrade while the generated agent rules kept
58
+ # advertising it as the authoritative "component registry mirror". Agents
59
+ # read stale component metadata and had no way to tell.
60
+ # The FileUtils.cp here was lifted from the Installer#mirror_registry that
61
+ # this same review deleted, and it carried that method's defect with it:
62
+ # containment was applied to the directory, so a symlinked
63
+ # .senren/registry.yml was followed and a file outside the app root was
64
+ # overwritten. Moving a bug is not fixing it. SafeWrite.copy! checks the
65
+ # destination file itself.
66
+ def refresh_registry_mirror
67
+ dest = paths.registry_mirror
68
+ SafeWrite.mkdir_p!(dest.dirname, paths.root, 'registry mirror')
69
+ SafeWrite.copy!(Senren::Rails.registry_path, dest, paths.root, 'registry mirror')
70
+ end
71
+ end
72
+ end
73
+ end
@@ -28,14 +28,17 @@ module Senren
28
28
  private
29
29
 
30
30
  def check(label)
31
- ok = false
32
31
  detail = nil
33
- begin
34
- ok = !yield.nil?
32
+ result = begin
33
+ yield
35
34
  rescue StandardError => e
36
35
  detail = e.message
36
+ nil
37
37
  end
38
- Result.new(label, ok, detail)
38
+
39
+ # Checks return either a boolean (File#exist?) or nil/truthy (defined?).
40
+ # Only a truthy result passes: `false` must fail, not slip through.
41
+ Result.new(label, result ? true : false, detail)
39
42
  end
40
43
 
41
44
  def gem_loadable?(name)
@@ -11,6 +11,29 @@ module Senren
11
11
  rake_path = File.expand_path('../../tasks/senren.rake', __dir__)
12
12
  app.paths['lib/tasks'] << rake_path if File.exist?(rake_path)
13
13
  end
14
+
15
+ # The gem's only production-boot behaviour, and it earns the exception:
16
+ # with app/components on the asset load path, Propshaft publishes
17
+ # component source.
18
+ #
19
+ # after_initialize rather than an `after:` anchor. Hosts append asset
20
+ # paths from config/initializers/assets.rb, so the check has to see a
21
+ # fully configured app. after_initialize is the only placement that
22
+ # guarantees that without reasoning about railtie order.
23
+ #
24
+ # This was first written as `after: :append_assets_path`, copied from
25
+ # sprockets-rails. Propshaft names its equivalent
26
+ # "propshaft.append_assets_path", and Rails resolves an unknown ordering
27
+ # anchor to an empty dependency set rather than raising — so the anchor
28
+ # was inert. It happened not to matter, because an application's own
29
+ # initializers run ahead of every engine's, but an inert anchor that reads
30
+ # like a guarantee is worse than no anchor.
31
+ initializer 'senren.rails.guard_asset_paths' do |app|
32
+ app.config.after_initialize do
33
+ require 'senren/rails/asset_path_guard'
34
+ Senren::Rails::AssetPathGuard.check!(app)
35
+ end
36
+ end
14
37
  end
15
38
  end
16
39
  end
@@ -1,4 +1,5 @@
1
1
  require 'pathname'
2
+ require 'senren/rails/safe_write'
2
3
 
3
4
  module Senren
4
5
  module Rails
@@ -32,13 +33,21 @@ module Senren
32
33
  def claude_md = root.join('CLAUDE.md')
33
34
  def codex_agents_md = root.join('AGENTS.md')
34
35
 
36
+ # Pathname#mkpath stops at File.directory?, which follows symlinks, so a
37
+ # checkout shipping app/components/senren as a link to somewhere outside
38
+ # the project had that link preserved and every later write redirected
39
+ # through it. SafeWrite refuses instead of silently accepting it.
35
40
  def ensure_dirs!
36
41
  [senren_dir, components_dir, stimulus_dir,
37
- stylesheet_path.dirname, github_dir, cursor_rules_dir].each(&:mkpath)
42
+ stylesheet_path.dirname, github_dir, cursor_rules_dir].each do |dir|
43
+ SafeWrite.mkdir_p!(dir, root, 'ensure_dirs!')
44
+ end
38
45
  end
39
46
 
40
47
  def ensure_agent_dirs!
41
- [senren_dir, github_dir, cursor_rules_dir].each(&:mkpath)
48
+ [senren_dir, github_dir, cursor_rules_dir].each do |dir|
49
+ SafeWrite.mkdir_p!(dir, root, 'ensure_agent_dirs!')
50
+ end
42
51
  end
43
52
  end
44
53
  end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Senren
4
+ module Rails
5
+ # Replaces the content between a START/END marker pair, preserving
6
+ # everything outside it.
7
+ #
8
+ # These markers delimit generated instructions inside files that humans and
9
+ # AI agents also edit (CLAUDE.md, AGENTS.md, .senren/skill.md). A tampered
10
+ # or duplicated marker pair is treated as an error rather than something to
11
+ # work around: silently "repairing" it produces a block that regeneration
12
+ # can never clean up again, which is indistinguishable from a persistent
13
+ # prompt injection.
14
+ module MarkerBlock
15
+ class MalformedMarkers < StandardError; end
16
+
17
+ module_function
18
+
19
+ # Returns `existing` with the marker block replaced by `generated`.
20
+ # Appends a fresh block when no markers are present yet.
21
+ def inject(existing, generated, start_marker:, end_marker:, label: nil)
22
+ existing = existing.to_s
23
+ body = generated.to_s.strip
24
+
25
+ assert_body_is_marker_free!(body, start_marker, end_marker, label)
26
+ assert_well_formed!(existing, start_marker, end_marker, label)
27
+
28
+ block = "#{start_marker}\n\n#{body}\n\n#{end_marker}"
29
+ return append(existing, block) unless existing.include?(start_marker)
30
+
31
+ pattern = /#{Regexp.escape(start_marker)}.*?#{Regexp.escape(end_marker)}/m
32
+ # Block form is required. Given a replacement *string*, String#sub
33
+ # expands \0, \1 and \& as backreferences: a body containing one would
34
+ # splice the old block — markers and all — back into the new one,
35
+ # resurrecting stale content and duplicating the delimiters. The block
36
+ # form takes the return value literally.
37
+ existing.sub(pattern) { block }
38
+ end
39
+
40
+ # A generated block that contains its own delimiters produces a file this
41
+ # module then refuses to parse: the writer corrupts its own output and the
42
+ # error blames the user for hand-editing. Fail at the source instead.
43
+ def assert_body_is_marker_free!(body, start_marker, end_marker, label)
44
+ found = [start_marker, end_marker].select { |marker| body.include?(marker) }
45
+ return if found.empty?
46
+
47
+ where = label ? " for #{label}" : ''
48
+ raise MalformedMarkers,
49
+ "Generated content#{where} contains #{found.join(' and ')}. " \
50
+ 'The data this block is rendered from must not contain the markers themselves.'
51
+ end
52
+
53
+ def append(existing, block)
54
+ head = existing.rstrip
55
+ head.empty? ? "#{block}\n" : "#{head}\n\n#{block}\n"
56
+ end
57
+
58
+ # A file is well formed when it has either no markers at all, or exactly
59
+ # one START followed by exactly one END.
60
+ def assert_well_formed!(existing, start_marker, end_marker, label)
61
+ starts = existing.scan(start_marker).size
62
+ ends = existing.scan(end_marker).size
63
+
64
+ return if starts.zero? && ends.zero?
65
+
66
+ where = label ? " in #{label}" : ''
67
+ if starts != 1 || ends != 1
68
+ raise MalformedMarkers,
69
+ "Expected exactly one #{start_marker} and one #{end_marker}#{where}, " \
70
+ "found #{starts} and #{ends}. Fix the markers by hand, then re-run the sync."
71
+ end
72
+
73
+ return if existing.index(start_marker) < existing.index(end_marker)
74
+
75
+ raise MalformedMarkers,
76
+ "#{end_marker} appears before #{start_marker}#{where}. " \
77
+ 'Fix the marker order by hand, then re-run the sync.'
78
+ end
79
+ end
80
+ end
81
+ end
@@ -3,29 +3,19 @@ require 'yaml'
3
3
  module Senren
4
4
  module Rails
5
5
  # Loads, validates, and queries the Senren component registry.
6
- #
7
- # reg = Senren::Rails::Registry.load!
8
- # reg.find("button") # => Component struct
9
- # reg.dependencies("dialog") # => [<button>]
10
- # reg.group("forms") # => [<form>, <input>, ...]
11
6
  class Registry
12
7
  include Enumerable
13
8
 
14
9
  REQUIRED_KEYS = %w[category client can_have_client files depends_on pairs_with variants accessibility ai].freeze
10
+ OPTIONAL_KEYS = %w[controller stub].freeze
11
+ ALLOWED_KEYS = (REQUIRED_KEYS + OPTIONAL_KEYS).freeze
15
12
  VALID_CATEGORIES = %w[actions forms overlays navigation layout data saas rich].freeze
13
+ NAME_PATTERN = /\A[a-z][a-z0-9_]*\z/
16
14
 
17
- Component = Struct.new(
18
- :name, :category, :client, :can_have_client, :controller, :stub,
19
- :files, :depends_on, :pairs_with, :variants, :accessibility,
20
- :use_for, :avoid,
21
- keyword_init: true
22
- ) do
15
+ Component = Struct.new(:name, :category, :client, :can_have_client, :controller, :stub, :files, :depends_on,
16
+ :pairs_with, :variants, :accessibility, :use_for, :avoid, keyword_init: true) do
23
17
  def stub? = stub == true
24
18
  def client? = client == true
25
-
26
- def to_h_full
27
- to_h.merge(stub: stub?, client: client?)
28
- end
29
19
  end
30
20
 
31
21
  attr_reader :components, :groups, :recipes
@@ -43,6 +33,7 @@ module Senren
43
33
  end
44
34
 
45
35
  def initialize(components_yaml, groups_yaml, recipes_yaml)
36
+ @raw_components = (components_yaml || {}).fetch('components', {})
46
37
  @components = parse_components(components_yaml)
47
38
  @groups = (groups_yaml || {}).fetch('groups', [])
48
39
  @recipes = (recipes_yaml || {}).fetch('recipes', {})
@@ -57,21 +48,11 @@ module Senren
57
48
  "Known: #{@components.keys.sort.join(', ')}"
58
49
  end
59
50
 
60
- def all
61
- @components.values
62
- end
51
+ def all = @components.values
52
+ def each(&) = all.each(&)
53
+ alias find_each each
63
54
 
64
- def each(&)
65
- all.each(&)
66
- end
67
-
68
- def find_each(&)
69
- each(&)
70
- end
71
-
72
- def names
73
- @components.keys
74
- end
55
+ def names = @components.keys
75
56
 
76
57
  def group(category_id)
77
58
  @components.values.select { |c| c.category == category_id.to_s }
@@ -118,12 +99,57 @@ module Senren
118
99
 
119
100
  def validate_components(errors)
120
101
  @components.each do |name, comp|
121
- errors << "#{name}: invalid category #{comp.category.inspect}" unless VALID_CATEGORIES.include?(comp.category)
122
- comp.depends_on.each do |dep|
123
- errors << "#{name}: depends_on unknown component #{dep.inspect}" unless @components.key?(dep)
124
- end
125
- errors << "#{name}: client=true but can_have_client=false" if comp.client && !comp.can_have_client
126
- errors << "#{name}: client=true requires a controller identifier" if comp.client && comp.controller.nil?
102
+ validate_component(name, comp, errors)
103
+ end
104
+ end
105
+
106
+ def validate_component(name, comp, errors)
107
+ validate_component_name(name, errors)
108
+ validate_component_keys(name, errors)
109
+ validate_component_category(name, comp, errors)
110
+ validate_component_dependencies(name, comp, errors)
111
+ validate_component_client_contract(name, comp, errors)
112
+ validate_component_file_paths(name, comp, errors)
113
+ end
114
+
115
+ # The file-path allowlist is derived from the component name, so an
116
+ # unconstrained name would validate a traversal path against itself.
117
+ def validate_component_name(name, errors)
118
+ return if NAME_PATTERN.match?(name)
119
+
120
+ errors << "#{name.inspect}: invalid component name (expected #{NAME_PATTERN.source})"
121
+ end
122
+
123
+ def validate_component_keys(name, errors)
124
+ extra_keys = @raw_components.fetch(name).keys - ALLOWED_KEYS
125
+ errors << "#{name}: unknown keys #{extra_keys.sort.join(', ')}" if extra_keys.any?
126
+ end
127
+
128
+ def validate_component_category(name, comp, errors)
129
+ return if VALID_CATEGORIES.include?(comp.category)
130
+
131
+ errors << "#{name}: invalid category #{comp.category.inspect}"
132
+ end
133
+
134
+ def validate_component_dependencies(name, comp, errors)
135
+ comp.depends_on.each do |dep|
136
+ errors << "#{name}: depends_on unknown component #{dep.inspect}" unless @components.key?(dep)
137
+ end
138
+ end
139
+
140
+ def validate_component_client_contract(name, comp, errors)
141
+ errors << "#{name}: client=true but can_have_client=false" if comp.client && !comp.can_have_client
142
+ errors << "#{name}: client=true requires a controller identifier" if comp.client && comp.controller.nil?
143
+ return unless comp.client && !controller_file?(name, comp)
144
+
145
+ errors << "#{name}: client=true requires a Stimulus controller file"
146
+ end
147
+
148
+ def validate_component_file_paths(name, comp, errors)
149
+ comp.files.each do |path|
150
+ next if allowed_component_file?(name, path)
151
+
152
+ errors << "#{name}: invalid file path #{path.inspect}"
127
153
  end
128
154
  end
129
155
 
@@ -156,6 +182,18 @@ module Senren
156
182
  ).freeze
157
183
  end
158
184
  end
185
+
186
+ def controller_file?(name, comp)
187
+ comp.files.include?("app/javascript/controllers/senren/#{name}_controller.js")
188
+ end
189
+
190
+ def allowed_component_file?(name, path)
191
+ [
192
+ "app/components/senren/#{name}_component.rb",
193
+ "app/components/senren/#{name}_component.html.erb",
194
+ "app/javascript/controllers/senren/#{name}_controller.js"
195
+ ].include?(path)
196
+ end
159
197
  end
160
198
  end
161
199
  end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'pathname'
5
+ require 'securerandom'
6
+
7
+ module Senren
8
+ module Rails
9
+ # Containment for everything this gem writes into a host app.
10
+ #
11
+ # The threat model is a repository you clone and run `senren:install` in.
12
+ # The *content* written is gem-controlled, so this is a file-creation and
13
+ # overwrite primitive rather than an arbitrary-content write — but a
14
+ # checkout that ships `app/components/senren` as a symlink pointing outside
15
+ # the project could previously redirect every copied file, and
16
+ # `write_adapter_file` reads its destination before rewriting it, so
17
+ # pre-existing content outside the checkout was modified too.
18
+ #
19
+ # Two independent defects made that possible, and both are fixed here:
20
+ #
21
+ # 1. Containment used Pathname#expand_path, which normalises `..`
22
+ # lexically and does NOT resolve symlinks. An escaping path therefore
23
+ # passed the start_with? check. Containment must compare realpath.
24
+ # 2. Symlinks were refused on the leaf only, never on an intermediate
25
+ # directory, and Pathname#mkpath stops at File.directory? — which
26
+ # follows a link — so a symlinked parent was preserved rather than
27
+ # replaced.
28
+ module SafeWrite
29
+ class Escape < StandardError; end
30
+
31
+ module_function
32
+
33
+ MAX_LINK_DEPTH = 32
34
+
35
+ # Where a write to `path` would actually land, with symlinks followed --
36
+ # including a DANGLING one, by reading its declared target rather than
37
+ # asking the filesystem to resolve it.
38
+ #
39
+ # Following the declared target is what makes the dangling case decidable.
40
+ # `exist?` is false on a broken link, so a check that resolved only
41
+ # existing ancestors would clear the parent directory and let the write
42
+ # land wherever the link points.
43
+ def real_target(path, depth = 0)
44
+ current = Pathname.new(path).expand_path
45
+ return current if depth > MAX_LINK_DEPTH
46
+
47
+ if current.symlink?
48
+ link = Pathname.new(File.readlink(current))
49
+ link = current.dirname + link unless link.absolute?
50
+ return real_target(link, depth + 1)
51
+ end
52
+ return current.realpath if current.exist?
53
+
54
+ resolve_existing_prefix(current)
55
+ rescue SystemCallError
56
+ Pathname.new(path).expand_path
57
+ end
58
+
59
+ # For a destination that does not exist yet: resolve the deepest ancestor
60
+ # that does, then re-append the part that is still missing.
61
+ #
62
+ # `symlink?` is part of the loop condition, not just `exist?`. A dangling
63
+ # link is invisible to `exist?`, so a walk that only tested existence
64
+ # stepped straight over an intermediate one and rebuilt the tail
65
+ # lexically -- reporting a path inside the root for a write that would
66
+ # land wherever the link pointed. Not reachable through today's callers,
67
+ # because each one runs mkdir_p! on the parent first and that catches it,
68
+ # but that is an implicit contract between caller and helper, and this
69
+ # cycle has already broken two of those.
70
+ def resolve_existing_prefix(path)
71
+ tail = []
72
+ current = path
73
+ until current.exist? || current.symlink? || current.root?
74
+ tail.unshift(current.basename)
75
+ current = current.parent
76
+ end
77
+ current = real_target(current) if current.symlink?
78
+ current = current.realpath if current.exist?
79
+ tail.reduce(current) { |acc, part| acc.join(part) }
80
+ end
81
+
82
+ # True when a write to `path` would land inside `root`.
83
+ #
84
+ # The property is containment, NOT the absence of symlinks. An earlier
85
+ # version refused every link on the path regardless of where it pointed,
86
+ # which broke `ln -s AGENTS.md CLAUDE.md` -- an ordinary way to keep one
87
+ # set of agent instructions -- and took `senren:add` down with it.
88
+ def inside?(path, root)
89
+ real_root = Pathname.new(root).expand_path
90
+ real_root = real_root.realpath if real_root.exist?
91
+ target = real_target(path)
92
+
93
+ target == real_root || target.to_s.start_with?("#{real_root}#{File::SEPARATOR}")
94
+ end
95
+
96
+ def assert_inside!(path, root, label)
97
+ return real_target(path) if inside?(path, root)
98
+
99
+ raise Escape,
100
+ "Refusing to write outside the app root: #{path} resolves to " \
101
+ "#{real_target(path)} (#{label})"
102
+ end
103
+
104
+ # Creates `dir`, refusing only when it would land outside the root.
105
+ def mkdir_p!(dir, root, label)
106
+ target = assert_inside!(dir, root, label)
107
+ FileUtils.mkdir_p(target)
108
+ target
109
+ end
110
+
111
+ # Resolves a destination for writing, or returns nil when it must be
112
+ # skipped. Raises when the destination escapes the app root.
113
+ def resolve(dest, root, label, io: $stdout)
114
+ assert_inside!(dest, root, label)
115
+ rescue Escape => e
116
+ io.puts " skip #{dest} (#{e.message}) [#{label}]"
117
+ nil
118
+ end
119
+
120
+ # An in-repo symlink is left in place: the write goes to what it points
121
+ # at, so `ln -s AGENTS.md CLAUDE.md` survives the write instead of being
122
+ # replaced by a regular file.
123
+ def assert_writable!(path, root, label)
124
+ assert_inside!(path, root, label)
125
+ end
126
+
127
+ # Every write this gem performs goes through here or #copy!.
128
+ #
129
+ # Hand-rolled File.write / FileUtils.cp is what let component source
130
+ # escape twice: once through a symlinked directory, and once -- after
131
+ # that was fixed -- through a symlinked destination *file*, because
132
+ # containment had been applied to the parent only. Writing via a
133
+ # temporary and renaming also means a killed process cannot truncate the
134
+ # file: rename is atomic and does not follow a symlink at the target.
135
+ def write!(path, content, root, label)
136
+ target = assert_writable!(path, root, label)
137
+ atomically(target) { |io| io.write(content) }
138
+ end
139
+
140
+ def copy!(source, path, root, label)
141
+ target = assert_writable!(path, root, label)
142
+ atomically(target) { |io| IO.copy_stream(source.to_s, io) }
143
+ end
144
+
145
+ # The temporary was the last way out of the app root, and containment
146
+ # never looked at it.
147
+ #
148
+ # `<dest>.<pid>.tmp` is guessable, and git stores symlinks, so a
149
+ # repository could ship `.senren/registry.yml.<pid>.tmp` as a link to
150
+ # something outside the checkout. `File.write` follows a symlink, so the
151
+ # write landed there with every containment check having passed —
152
+ # verified before this was changed.
153
+ #
154
+ # Two independent fixes, because either alone is thin. A random suffix
155
+ # removes the guess, and O_CREAT|O_EXCL refuses to open an existing path
156
+ # at all and does not follow a final symlink, so a lucky guess still
157
+ # fails. 0o644 rather than 0o600 keeps the permissions a plain write
158
+ # would have produced.
159
+ def atomically(target, &)
160
+ tmp = Pathname.new("#{target}.#{SecureRandom.hex(12)}.tmp")
161
+ File.open(tmp, File::WRONLY | File::CREAT | File::EXCL, 0o644, &)
162
+ File.rename(tmp, target)
163
+ target
164
+ ensure
165
+ FileUtils.rm_f(tmp) if tmp&.exist?
166
+ end
167
+ end
168
+ end
169
+ end
@@ -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
@@ -30,16 +32,23 @@ module Senren
30
32
  installed = installed_names
31
33
  body = render(installed)
32
34
 
33
- paths.senren_dir.mkpath
35
+ SafeWrite.mkdir_p!(paths.senren_dir, paths.root, 'skill.md')
34
36
  existing = paths.skill_file.exist? ? paths.skill_file.read : default_outer_template
35
37
 
36
38
  new_content = inject(existing, body)
37
- paths.skill_file.write(new_content)
39
+ atomic_write(paths.skill_file, new_content)
38
40
  paths.skill_file
39
41
  end
40
42
 
41
43
  private
42
44
 
45
+ # Routed through SafeWrite rather than hand-rolled. assert_inside! plus
46
+ # rename was already safe for an existing symlink, but not for a dangling
47
+ # one, and two other writers proved that hand-rolled writes drift.
48
+ def atomic_write(path, content)
49
+ SafeWrite.write!(path, content, paths.root, path.to_s)
50
+ end
51
+
43
52
  def installed_names
44
53
  path = paths.installed_components
45
54
  return [] unless path.exist?
@@ -50,7 +59,7 @@ module Senren
50
59
 
51
60
  def render(installed_names)
52
61
  if installed_names.empty?
53
- return '_No Senren components installed yet. Run `bin/rails senren:add <name>...` to install components._'
62
+ return '_No Senren components installed yet. Run `bin/rails senren:add button` to install components._'
54
63
  end
55
64
 
56
65
  lines = []
@@ -110,21 +119,51 @@ module Senren
110
119
  end
111
120
  end
112
121
 
122
+ # Describes what is installed, not what the registry offers.
123
+ #
124
+ # This rendered the registry default, so after `senren:add select
125
+ # --no-client` the skill file told agents to use
126
+ # `senren--select` and named a controller file that is not on disk. The
127
+ # copier computes the truth and writes it to the ledger — with a comment
128
+ # saying "never record client behavior in the ledger that was not
129
+ # installed" — and nothing read it back. The gem's flagship feature was
130
+ # wrong for precisely the installs where the flag was exercised.
113
131
  def client_summary(comp)
114
132
  return 'none' unless comp.client?
133
+ return 'none (installed without its Stimulus controller)' unless installed_with_client?(comp.name)
115
134
 
116
135
  path = "app/javascript/controllers/senren/#{comp.name}_controller.js"
117
136
  "Stimulus `#{comp.controller}` (`#{path}`)"
118
137
  end
119
138
 
139
+ # nil when the ledger predates this field, in which case the registry
140
+ # default is the best available answer.
141
+ def installed_with_client?(name)
142
+ entry = ledger_entries.find { |e| e['name'] == name }
143
+ return true if entry.nil? || !entry.key?('client')
144
+
145
+ entry['client']
146
+ end
147
+
148
+ def ledger_entries
149
+ @ledger_entries ||= begin
150
+ path = paths.installed_components
151
+ path.exist? ? Array((YAML.safe_load_file(path) || {})['installed']) : []
152
+ end
153
+ end
154
+
120
155
  def format_list(items)
121
156
  return '_none_' if items.empty?
122
157
 
123
158
  items.map { |i| "`#{i}`" }.join(', ')
124
159
  end
125
160
 
161
+ # NAME_PATTERN accepts consecutive underscores, and "foo__bar".split("_")
162
+ # yields an empty segment, so w[0] was nil and this raised NoMethodError —
163
+ # after the files were copied and the ledger written, leaving a
164
+ # half-completed install. Empty segments are dropped instead.
126
165
  def humanize(name)
127
- name.split('_').map { |w| w[0].upcase + w[1..] }.join
166
+ name.split('_').reject(&:empty?).map { |w| w[0].upcase + w[1..] }.join
128
167
  end
129
168
 
130
169
  def ruby_class_for(name)
@@ -132,13 +171,10 @@ module Senren
132
171
  end
133
172
 
134
173
  def inject(existing, generated_body)
135
- if existing.include?(START_MARKER) && existing.include?(END_MARKER)
136
- before = existing.split(START_MARKER, 2).first
137
- after = existing.split(END_MARKER, 2).last
138
- "#{before}#{START_MARKER}\n\n#{generated_body}\n\n#{END_MARKER}#{after}"
139
- else
140
- "#{existing.rstrip}\n\n#{START_MARKER}\n\n#{generated_body}\n\n#{END_MARKER}\n"
141
- end
174
+ MarkerBlock.inject(
175
+ existing, generated_body,
176
+ start_marker: START_MARKER, end_marker: END_MARKER, label: paths.skill_file.to_s
177
+ )
142
178
  end
143
179
 
144
180
  def default_outer_template
@@ -151,7 +187,7 @@ module Senren
151
187
 
152
188
  - Use Senren components before writing custom HTML.
153
189
  - Use ViewComponent for reusable UI; Turbo for server state; Stimulus for local behavior only.
154
- - Do not introduce React, Vue, Alpine, or any external state framework.
190
+ - Keep interactivity in Stimulus rather than a client-side framework.
155
191
  - Do not hard-code colors; use semantic Tailwind tokens like `bg-background`, `text-foreground`, `bg-primary`.
156
192
 
157
193
  ## Installed Components