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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +124 -2
- data/CONTRIBUTING.md +79 -9
- data/README.md +149 -14
- data/Rakefile +14 -1
- data/docs/components.md +222 -0
- data/docs/hot_reload.md +103 -0
- data/docs/performance_testing.md +38 -0
- data/lib/commands/senren/add/add_command.rb +35 -0
- data/lib/generators/senren/component/templates/controller.js.tt +7 -4
- data/lib/generators/senren/install/install_generator.rb +74 -0
- data/lib/generators/senren/install/templates/base_component.rb.tt +89 -8
- data/lib/generators/senren/install/templates/conventions.md.tt +26 -7
- data/lib/senren/rails/agent_rules_writer.rb +62 -19
- data/lib/senren/rails/asset_path_guard.rb +128 -0
- data/lib/senren/rails/base_component_patch.rb +64 -0
- data/lib/senren/rails/component_copier.rb +138 -18
- data/lib/senren/rails/component_installer.rb +73 -0
- data/lib/senren/rails/doctor.rb +7 -4
- data/lib/senren/rails/engine.rb +23 -0
- data/lib/senren/rails/host_paths.rb +11 -2
- data/lib/senren/rails/marker_block.rb +81 -0
- data/lib/senren/rails/registry.rb +73 -35
- data/lib/senren/rails/safe_write.rb +169 -0
- data/lib/senren/rails/skill_writer.rb +48 -12
- data/lib/senren/rails/version.rb +1 -1
- data/lib/senren/rails.rb +2 -1
- data/lib/senren-ui.rb +15 -0
- data/lib/tasks/senren.rake +53 -34
- data/registry/components.yml +45 -0
- data/registry/recipes.yml +12 -0
- data/templates/components/alert_dialog/alert_dialog_component.rb +1 -1
- data/templates/components/api_key_field/api_key_field_component.html.erb +1 -1
- data/templates/components/aspect_ratio/aspect_ratio_component.rb +7 -0
- data/templates/components/avatar/avatar_component.rb +8 -1
- data/templates/components/billing_plan_card/billing_plan_card_component.html.erb +1 -1
- data/templates/components/breadcrumb/breadcrumb_component.rb +2 -2
- data/templates/components/button/button_component.html.erb +1 -1
- data/templates/components/carousel/carousel_component.rb +1 -1
- data/templates/components/cart/cart_component.html.erb +61 -0
- data/templates/components/cart/cart_component.rb +71 -0
- data/templates/components/checkbox/checkbox_component.rb +1 -1
- data/templates/components/clipboard/clipboard_component.html.erb +1 -1
- data/templates/components/command/command_component.rb +2 -2
- data/templates/components/date_picker/date_picker_component.html.erb +1 -1
- data/templates/components/dialog/dialog_component.rb +1 -1
- data/templates/components/dropdown_menu/dropdown_menu_component.rb +10 -7
- data/templates/components/form/form_component.html.erb +8 -1
- data/templates/components/form/form_component.rb +12 -2
- data/templates/components/input/input_component.html.erb +1 -1
- data/templates/components/input/input_component.rb +19 -0
- data/templates/components/invite_member_dialog/invite_member_dialog_component.rb +1 -1
- data/templates/components/label/label_component.html.erb +1 -2
- data/templates/components/label/label_component.rb +12 -2
- data/templates/components/link/link_component.html.erb +1 -1
- data/templates/components/native_select/native_select_component.html.erb +19 -5
- data/templates/components/native_select/native_select_component.rb +17 -5
- data/templates/components/pagination/pagination_component.rb +2 -1
- data/templates/components/product_card/product_card_component.html.erb +38 -0
- data/templates/components/product_card/product_card_component.rb +49 -0
- data/templates/components/rich_text_editor_lite/rich_text_editor_lite_component.html.erb +1 -1
- data/templates/components/rich_text_editor_lite/rich_text_editor_lite_component.rb +1 -1
- data/templates/components/separator/separator_component.rb +7 -0
- data/templates/components/sheet/sheet_component.rb +1 -1
- data/templates/components/sidebar/sidebar_component.rb +2 -2
- data/templates/components/switch/switch_component.html.erb +2 -2
- data/templates/components/tooltip/tooltip_component.rb +2 -2
- data/templates/components/top_nav/top_nav_component.rb +2 -2
- data/templates/components/typography/typography_component.rb +7 -0
- data/templates/controllers/accordion_controller.js +1 -1
- data/templates/controllers/alert_dialog_controller.js +31 -7
- data/templates/controllers/cart_controller.js +83 -0
- data/templates/controllers/clipboard_controller.js +12 -1
- data/templates/controllers/command_controller.js +3 -4
- data/templates/controllers/context_menu_controller.js +38 -11
- data/templates/controllers/data_table_controller.js +8 -3
- data/templates/controllers/dialog_controller.js +47 -21
- data/templates/controllers/dropdown_menu_controller.js +40 -27
- data/templates/controllers/hover_card_controller.js +8 -0
- data/templates/controllers/invite_member_dialog_controller.js +6 -0
- data/templates/controllers/masked_input_controller.js +8 -1
- data/templates/controllers/popover_controller.js +25 -10
- data/templates/controllers/rich_text_editor_lite_controller.js +175 -28
- data/templates/controllers/sheet_controller.js +41 -11
- metadata +20 -6
- 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
|
data/lib/senren/rails/doctor.rb
CHANGED
|
@@ -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
|
-
|
|
32
|
+
result = begin
|
|
33
|
+
yield
|
|
35
34
|
rescue StandardError => e
|
|
36
35
|
detail = e.message
|
|
36
|
+
nil
|
|
37
37
|
end
|
|
38
|
-
|
|
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)
|
data/lib/senren/rails/engine.rb
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
51
|
+
def all = @components.values
|
|
52
|
+
def each(&) = all.each(&)
|
|
53
|
+
alias find_each each
|
|
63
54
|
|
|
64
|
-
def
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
-
|
|
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
|