senren-ui 0.1.6 → 0.3.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 +215 -0
- data/CONTRIBUTING.md +41 -3
- data/README.md +138 -33
- data/Rakefile +14 -1
- data/docs/components.md +10 -10
- data/docs/hot_reload.md +103 -0
- data/docs/performance_testing.md +7 -3
- data/docs/visual_style.md +80 -0
- data/lib/generators/senren/component/templates/controller.js.tt +7 -4
- data/lib/generators/senren/install/install_generator.rb +77 -0
- data/lib/generators/senren/install/templates/base_component.rb.tt +142 -4
- data/lib/generators/senren/install/templates/conventions.md.tt +57 -1
- data/lib/generators/senren/install/templates/senren_themes.css.tt +339 -0
- data/lib/senren/rails/agent_rules_writer.rb +65 -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 +101 -50
- data/lib/senren/rails/component_installer.rb +26 -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 +10 -4
- data/lib/senren/rails/safe_write.rb +169 -0
- data/lib/senren/rails/skill_writer.rb +47 -11
- data/lib/senren/rails/version.rb +1 -1
- data/lib/senren/rails.rb +1 -1
- data/lib/senren-ui.rb +15 -0
- data/lib/tasks/senren.rake +43 -17
- data/registry/components.yml +45 -0
- data/registry/recipes.yml +12 -0
- data/templates/components/accordion/accordion_component.html.erb +3 -3
- data/templates/components/alert_dialog/alert_dialog_component.html.erb +6 -3
- data/templates/components/alert_dialog/alert_dialog_component.rb +5 -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/breadcrumb/breadcrumb_component.html.erb +8 -6
- data/templates/components/button/button_component.html.erb +1 -1
- data/templates/components/button/button_component.rb +27 -1
- data/templates/components/calendar/calendar_component.html.erb +2 -2
- data/templates/components/card/card_component.html.erb +4 -6
- data/templates/components/carousel/carousel_component.html.erb +1 -1
- data/templates/components/cart/cart_component.html.erb +67 -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 +3 -3
- data/templates/components/collapsible/collapsible_component.html.erb +3 -3
- data/templates/components/combobox/combobox_component.html.erb +3 -3
- data/templates/components/command/command_component.html.erb +1 -1
- data/templates/components/command/command_component.rb +1 -1
- data/templates/components/context_menu/context_menu_component.html.erb +6 -3
- data/templates/components/date_picker/date_picker_component.html.erb +3 -3
- data/templates/components/dialog/dialog_component.html.erb +7 -3
- data/templates/components/dialog/dialog_component.rb +12 -1
- data/templates/components/dropdown_menu/dropdown_menu_component.html.erb +6 -3
- data/templates/components/dropdown_menu/dropdown_menu_component.rb +57 -5
- data/templates/components/form/form_component.rb +9 -1
- data/templates/components/hover_card/hover_card_component.html.erb +6 -3
- data/templates/components/invite_member_dialog/invite_member_dialog_component.html.erb +3 -2
- data/templates/components/invite_member_dialog/invite_member_dialog_component.rb +1 -1
- data/templates/components/link/link_component.html.erb +1 -1
- data/templates/components/native_select/native_select_component.html.erb +2 -2
- data/templates/components/pagination/pagination_component.html.erb +2 -2
- data/templates/components/popover/popover_component.html.erb +6 -3
- 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/progress/progress_component.html.erb +2 -2
- 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/search_input/search_input_component.html.erb +2 -2
- data/templates/components/separator/separator_component.rb +7 -0
- data/templates/components/sheet/sheet_component.html.erb +13 -4
- data/templates/components/sheet/sheet_component.rb +12 -1
- data/templates/components/shortcut_key/shortcut_key_component.html.erb +2 -2
- data/templates/components/sidebar/sidebar_component.html.erb +2 -2
- data/templates/components/switch/switch_component.html.erb +1 -1
- data/templates/components/table/table_component.html.erb +2 -2
- data/templates/components/tabs/tabs_component.html.erb +3 -3
- data/templates/components/tooltip/tooltip_component.html.erb +5 -3
- data/templates/components/tooltip/tooltip_component.rb +2 -2
- data/templates/components/top_nav/top_nav_component.html.erb +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 +85 -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 +165 -28
- data/templates/controllers/sheet_controller.js +41 -11
- metadata +14 -17
- data/lib/senren/rails/installer.rb +0 -85
|
@@ -1,8 +1,11 @@
|
|
|
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
|
|
@@ -15,40 +18,7 @@ module Senren
|
|
|
15
18
|
'../../generators/senren/install/templates', __dir__
|
|
16
19
|
).freeze
|
|
17
20
|
BASE_COMPONENT_TEMPLATE = File.join(INSTALL_GENERATOR_TEMPLATES, 'base_component.rb.tt').freeze
|
|
18
|
-
BASE_URL_HELPER_PATCH =
|
|
19
|
-
|
|
20
|
-
# Added by senren:add for compatibility with URL-aware component templates.
|
|
21
|
-
require 'uri'
|
|
22
|
-
|
|
23
|
-
module Senren
|
|
24
|
-
class BaseComponent
|
|
25
|
-
SAFE_URL_PROTOCOLS = %w[http https mailto tel].freeze unless const_defined?(:SAFE_URL_PROTOCOLS)
|
|
26
|
-
SAFE_MEDIA_URL_PROTOCOLS = %w[http https].freeze unless const_defined?(:SAFE_MEDIA_URL_PROTOCOLS)
|
|
27
|
-
|
|
28
|
-
private
|
|
29
|
-
|
|
30
|
-
def safe_url(value, fallback: '#', protocols: SAFE_URL_PROTOCOLS)
|
|
31
|
-
url = value.to_s.strip
|
|
32
|
-
return fallback if url.empty?
|
|
33
|
-
return url if url.start_with?('#')
|
|
34
|
-
return url if url.start_with?('/') && !url.start_with?('//')
|
|
35
|
-
|
|
36
|
-
uri = URI.parse(url)
|
|
37
|
-
return url if uri.scheme && Array(protocols).map(&:to_s).include?(uri.scheme.downcase)
|
|
38
|
-
return fallback if uri.host
|
|
39
|
-
return url unless uri.scheme
|
|
40
|
-
|
|
41
|
-
fallback
|
|
42
|
-
rescue URI::InvalidURIError
|
|
43
|
-
fallback
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
def safe_media_url(value, fallback: nil)
|
|
47
|
-
safe_url(value, fallback: fallback, protocols: SAFE_MEDIA_URL_PROTOCOLS)
|
|
48
|
-
end
|
|
49
|
-
end
|
|
50
|
-
end
|
|
51
|
-
RUBY
|
|
21
|
+
BASE_URL_HELPER_PATCH = BaseComponentPatch::URL_HELPERS
|
|
52
22
|
|
|
53
23
|
attr_reader :registry, :paths, :stdout
|
|
54
24
|
|
|
@@ -62,30 +32,64 @@ module Senren
|
|
|
62
32
|
# Returns the ordered list of component names actually installed.
|
|
63
33
|
def install(component_names, client_override: nil, force: false)
|
|
64
34
|
wanted = registry.dependencies(*component_names)
|
|
35
|
+
validate_client_override!(component_names, client_override)
|
|
65
36
|
paths.ensure_dirs!
|
|
66
37
|
ensure_base_component_url_helpers!
|
|
67
38
|
|
|
39
|
+
requested = Array(component_names).map(&:to_s)
|
|
68
40
|
wanted.each do |name|
|
|
69
41
|
comp = registry.fetch(name)
|
|
70
|
-
install_component(comp, client_override: client_override, force: force)
|
|
42
|
+
install_component(comp, client_override: override_for(name, requested, client_override), force: force)
|
|
71
43
|
end
|
|
72
44
|
|
|
73
|
-
update_installed_ledger(wanted, client_override: client_override)
|
|
45
|
+
update_installed_ledger(wanted, requested: requested, client_override: client_override)
|
|
74
46
|
wanted
|
|
75
47
|
end
|
|
76
48
|
|
|
77
49
|
private
|
|
78
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
|
+
|
|
79
64
|
def ensure_base_component_url_helpers!
|
|
80
|
-
|
|
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?
|
|
81
72
|
return if base_component_has_url_helpers?
|
|
82
73
|
|
|
83
|
-
File.open(
|
|
84
|
-
|
|
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)"
|
|
85
82
|
return
|
|
86
83
|
end
|
|
87
84
|
|
|
88
|
-
copy_file(BASE_COMPONENT_TEMPLATE,
|
|
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?
|
|
89
93
|
end
|
|
90
94
|
|
|
91
95
|
def base_component_has_url_helpers?
|
|
@@ -106,11 +110,28 @@ module Senren
|
|
|
106
110
|
end
|
|
107
111
|
end
|
|
108
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
|
+
|
|
109
130
|
def effective_client_for(comp, override)
|
|
110
|
-
|
|
111
|
-
return false unless comp.can_have_client
|
|
131
|
+
desired = override.nil? ? comp.client? : (comp.can_have_client && override)
|
|
112
132
|
|
|
113
|
-
|
|
133
|
+
# Never record client behavior in the ledger that was not installed.
|
|
134
|
+
desired && !controller_file_for(comp).nil?
|
|
114
135
|
end
|
|
115
136
|
|
|
116
137
|
def source_for(comp, relative)
|
|
@@ -142,18 +163,46 @@ module Senren
|
|
|
142
163
|
def copy_file(src, dest, force:, label:)
|
|
143
164
|
raise MissingTemplate, "Missing component template: #{src} (#{label})" unless File.exist?(src)
|
|
144
165
|
|
|
166
|
+
dest = assert_inside_host_root!(dest, label)
|
|
167
|
+
return if refuse_symlink?(dest, label)
|
|
168
|
+
|
|
145
169
|
if File.exist?(dest) && !force
|
|
146
170
|
stdout.puts " skip #{dest} (already exists)"
|
|
147
171
|
return
|
|
148
172
|
end
|
|
149
|
-
|
|
150
|
-
|
|
173
|
+
SafeWrite.mkdir_p!(File.dirname(dest), paths.root, label)
|
|
174
|
+
SafeWrite.copy!(src, dest, paths.root, label)
|
|
151
175
|
stdout.puts " copy #{dest}"
|
|
152
176
|
end
|
|
153
177
|
|
|
154
|
-
|
|
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:)
|
|
155
204
|
ledger_path = paths.installed_components
|
|
156
|
-
ledger =
|
|
205
|
+
ledger = load_ledger(ledger_path)
|
|
157
206
|
installed = ledger['installed'] ||= []
|
|
158
207
|
|
|
159
208
|
names.each do |name|
|
|
@@ -162,7 +211,9 @@ module Senren
|
|
|
162
211
|
'name' => name,
|
|
163
212
|
'version' => Senren::Rails::VERSION,
|
|
164
213
|
'installed_at' => Time.now.utc.iso8601,
|
|
165
|
-
'client' => effective_client_for(
|
|
214
|
+
'client' => effective_client_for(
|
|
215
|
+
registry.fetch(name), override_for(name, requested, client_override)
|
|
216
|
+
)
|
|
166
217
|
}
|
|
167
218
|
if existing
|
|
168
219
|
existing.merge!(attrs.except('installed_at'))
|
|
@@ -172,8 +223,8 @@ module Senren
|
|
|
172
223
|
end
|
|
173
224
|
|
|
174
225
|
installed.sort_by! { |e| e['name'] }
|
|
175
|
-
ledger_path.parent.
|
|
176
|
-
|
|
226
|
+
SafeWrite.mkdir_p!(ledger_path.parent, paths.root, 'ledger')
|
|
227
|
+
SafeWrite.write!(ledger_path, YAML.dump(ledger), paths.root, 'ledger')
|
|
177
228
|
end
|
|
178
229
|
end
|
|
179
230
|
end
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'fileutils'
|
|
3
4
|
require 'senren/rails/agent_rules_writer'
|
|
4
5
|
require 'senren/rails/component_copier'
|
|
5
6
|
require 'senren/rails/host_paths'
|
|
@@ -33,15 +34,40 @@ module Senren
|
|
|
33
34
|
normalized_names = self.class.normalize_names(names)
|
|
34
35
|
raise ArgumentError, USAGE if normalized_names.empty?
|
|
35
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
|
+
|
|
36
43
|
installed = ComponentCopier.new(registry: registry, paths: paths, stdout: stdout)
|
|
37
44
|
.install(normalized_names, client_override: client_override, force: force)
|
|
38
45
|
|
|
46
|
+
refresh_registry_mirror
|
|
39
47
|
SkillWriter.new(registry: registry, paths: paths).sync!
|
|
40
48
|
AgentRulesWriter.new(registry: registry, paths: paths).sync!
|
|
41
49
|
|
|
42
50
|
stdout.puts "Installed: #{installed.join(', ')}"
|
|
43
51
|
installed
|
|
44
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
|
|
45
71
|
end
|
|
46
72
|
end
|
|
47
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
|
|
@@ -10,15 +10,12 @@ module Senren
|
|
|
10
10
|
OPTIONAL_KEYS = %w[controller stub].freeze
|
|
11
11
|
ALLOWED_KEYS = (REQUIRED_KEYS + OPTIONAL_KEYS).freeze
|
|
12
12
|
VALID_CATEGORIES = %w[actions forms overlays navigation layout data saas rich].freeze
|
|
13
|
+
NAME_PATTERN = /\A[a-z][a-z0-9_]*\z/
|
|
13
14
|
|
|
14
15
|
Component = Struct.new(:name, :category, :client, :can_have_client, :controller, :stub, :files, :depends_on,
|
|
15
16
|
:pairs_with, :variants, :accessibility, :use_for, :avoid, keyword_init: true) do
|
|
16
17
|
def stub? = stub == true
|
|
17
18
|
def client? = client == true
|
|
18
|
-
|
|
19
|
-
def to_h_full
|
|
20
|
-
to_h.merge(stub: stub?, client: client?)
|
|
21
|
-
end
|
|
22
19
|
end
|
|
23
20
|
|
|
24
21
|
attr_reader :components, :groups, :recipes
|
|
@@ -107,6 +104,7 @@ module Senren
|
|
|
107
104
|
end
|
|
108
105
|
|
|
109
106
|
def validate_component(name, comp, errors)
|
|
107
|
+
validate_component_name(name, errors)
|
|
110
108
|
validate_component_keys(name, errors)
|
|
111
109
|
validate_component_category(name, comp, errors)
|
|
112
110
|
validate_component_dependencies(name, comp, errors)
|
|
@@ -114,6 +112,14 @@ module Senren
|
|
|
114
112
|
validate_component_file_paths(name, comp, errors)
|
|
115
113
|
end
|
|
116
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
|
+
|
|
117
123
|
def validate_component_keys(name, errors)
|
|
118
124
|
extra_keys = @raw_components.fetch(name).keys - ALLOWED_KEYS
|
|
119
125
|
errors << "#{name}: unknown keys #{extra_keys.sort.join(', ')}" if extra_keys.any?
|
|
@@ -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
|