react_on_rails 17.1.0 → 17.2.0.rc.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/Gemfile +1 -1
- data/Gemfile.development_dependencies +2 -2
- data/Gemfile.lock +65 -73
- data/Steepfile +1 -0
- data/docs/agent/install-and-upgrade.md +8 -3
- data/docs/agent/rsc-adoption.md +3 -0
- data/lib/generators/react_on_rails/base_generator.rb +2 -1
- data/lib/generators/react_on_rails/generated_webpack_config_pair.rb +84 -0
- data/lib/generators/react_on_rails/generator_helper.rb +2 -4
- data/lib/generators/react_on_rails/generator_messages.rb +1 -1
- data/lib/generators/react_on_rails/install_generator.rb +2 -1
- data/lib/generators/react_on_rails/js_dependency_manager.rb +14 -14
- data/lib/generators/react_on_rails/pro/USAGE +14 -3
- data/lib/generators/react_on_rails/pro_generator.rb +1 -1
- data/lib/generators/react_on_rails/pro_setup.rb +41 -213
- data/lib/generators/react_on_rails/rsc_setup.rb +7 -16
- data/lib/generators/react_on_rails/templates/pro/base/config/initializers/react_on_rails_pro.rb.tt +1 -1
- data/lib/react_on_rails/agent_guardrails.rb +217 -39
- data/lib/react_on_rails/dev/process_manager.rb +14 -0
- data/lib/react_on_rails/dev/server_manager.rb +1727 -103
- data/lib/react_on_rails/doctor.rb +875 -44
- data/lib/react_on_rails/helper.rb +3 -3
- data/lib/react_on_rails/react_component/render_options.rb +5 -7
- data/lib/react_on_rails/server_rendering_pool/ruby_embedded_java_script.rb +27 -116
- data/lib/react_on_rails/utils.rb +65 -0
- data/lib/react_on_rails/version.rb +1 -1
- data/lib/react_on_rails/version_checker/lockfile_resolution.rb +170 -0
- data/lib/react_on_rails/version_checker.rb +43 -22
- data/rakelib/run_rspec.rake +6 -1
- data/sig/react_on_rails/configuration.rbs +28 -26
- data/sig/react_on_rails/controller.rbs +2 -1
- data/sig/react_on_rails/dev/process_manager.rbs +1 -0
- data/sig/react_on_rails/dev/server_manager.rbs +150 -10
- data/sig/react_on_rails/git_utils.rbs +5 -1
- data/sig/react_on_rails/locales.rbs +4 -4
- data/sig/react_on_rails/prerender_error.rbs +2 -2
- data/sig/react_on_rails/test_helper.rbs +15 -1
- data/sig/react_on_rails/utils.rbs +2 -2
- data/sig/react_on_rails/version_checker/lockfile_resolution.rbs +31 -0
- data/sig/react_on_rails/version_checker.rbs +30 -7
- metadata +4 -1
|
@@ -50,84 +50,254 @@ module ReactOnRails
|
|
|
50
50
|
rails_root ? rails_root.to_s : Dir.pwd
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
+
module FileOwnership
|
|
54
|
+
module_function
|
|
55
|
+
|
|
56
|
+
def preserve(existing_stat, path)
|
|
57
|
+
return unless existing_stat
|
|
58
|
+
|
|
59
|
+
File.chown(existing_stat.uid, existing_stat.gid, path)
|
|
60
|
+
rescue Errno::EPERM, Errno::EINVAL, NotImplementedError
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
private_constant :FileOwnership
|
|
65
|
+
|
|
66
|
+
module FileWriter
|
|
67
|
+
module_function
|
|
68
|
+
|
|
69
|
+
def ensure_existing_writable!(path)
|
|
70
|
+
return unless File.exist?(path)
|
|
71
|
+
return if File.writable?(path)
|
|
72
|
+
|
|
73
|
+
raise Errno::EACCES, path
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Prefer rename over truncate-and-write so interrupted writes cannot leave a partial destination.
|
|
77
|
+
# Copied guardrails may retain the old in-place behavior when directory permissions block replacement.
|
|
78
|
+
def write(path, content, new_file_mode: 0o644, allow_in_place_fallback: false, ensure_executable: false)
|
|
79
|
+
existing_stat = File.stat(path) if File.exist?(path)
|
|
80
|
+
temp = Tempfile.create([".#{File.basename(path)}", ".tmp"], File.dirname(path))
|
|
81
|
+
begin
|
|
82
|
+
temp.write(content)
|
|
83
|
+
temp.flush
|
|
84
|
+
temp.fsync
|
|
85
|
+
temp.close
|
|
86
|
+
FileOwnership.preserve(existing_stat, temp.path)
|
|
87
|
+
File.chmod(existing_stat ? existing_stat.mode & 0o7777 : new_file_mode, temp.path)
|
|
88
|
+
File.rename(temp.path, path)
|
|
89
|
+
:atomic
|
|
90
|
+
rescue StandardError
|
|
91
|
+
cleanup_tempfile(temp)
|
|
92
|
+
raise
|
|
93
|
+
end
|
|
94
|
+
rescue Errno::EACCES, Errno::EPERM
|
|
95
|
+
raise unless existing_stat && allow_in_place_fallback
|
|
96
|
+
|
|
97
|
+
prepare_in_place(path, ensure_executable)
|
|
98
|
+
File.write(path, content)
|
|
99
|
+
:in_place
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def finish_copy(path, ensure_executable:, strategy:)
|
|
103
|
+
return unless ensure_executable
|
|
104
|
+
return if strategy == :in_place
|
|
105
|
+
|
|
106
|
+
File.chmod(0o755, path)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def prepare_in_place(path, ensure_executable)
|
|
110
|
+
return unless ensure_executable
|
|
111
|
+
|
|
112
|
+
File.chmod(0o755, path)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def cleanup_tempfile(temp)
|
|
116
|
+
close_tempfile(temp)
|
|
117
|
+
remove_tempfile(temp.path)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def close_tempfile(temp)
|
|
121
|
+
temp.close unless temp.closed?
|
|
122
|
+
rescue StandardError
|
|
123
|
+
nil
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def remove_tempfile(path)
|
|
127
|
+
FileUtils.rm_f(path)
|
|
128
|
+
rescue StandardError
|
|
129
|
+
nil
|
|
130
|
+
end
|
|
131
|
+
private_class_method :prepare_in_place, :cleanup_tempfile, :close_tempfile, :remove_tempfile
|
|
132
|
+
end
|
|
133
|
+
private_constant :FileWriter
|
|
134
|
+
|
|
135
|
+
module WritePath
|
|
136
|
+
module_function
|
|
137
|
+
|
|
138
|
+
def resolve_root(path)
|
|
139
|
+
resolve(path)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def resolve_within(path, destination_root, resolved_destination_root)
|
|
143
|
+
resolved_path = resolve(path)
|
|
144
|
+
raise Error, "#{path} resolves outside the destination root" unless
|
|
145
|
+
within_destination_root?(resolved_path, resolved_destination_root)
|
|
146
|
+
|
|
147
|
+
managed_path_has_symlink?(path, destination_root) ? resolved_path : path
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def resolve(path)
|
|
151
|
+
path = File.realdirpath(path) if File.symlink?(path)
|
|
152
|
+
|
|
153
|
+
existing_path = path
|
|
154
|
+
missing_components = []
|
|
155
|
+
until path_entry_exists?(existing_path)
|
|
156
|
+
parent = File.dirname(existing_path)
|
|
157
|
+
raise Errno::ENOENT, path if parent == existing_path
|
|
158
|
+
|
|
159
|
+
missing_components.unshift(File.basename(existing_path))
|
|
160
|
+
existing_path = parent
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
resolved_existing_path = if File.symlink?(existing_path)
|
|
164
|
+
File.realdirpath(existing_path)
|
|
165
|
+
else
|
|
166
|
+
File.realpath(existing_path)
|
|
167
|
+
end
|
|
168
|
+
File.join(resolved_existing_path, *missing_components)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def within_destination_root?(path, resolved_destination_root)
|
|
172
|
+
return true if path == resolved_destination_root
|
|
173
|
+
|
|
174
|
+
root_prefix = if resolved_destination_root.end_with?(File::SEPARATOR)
|
|
175
|
+
resolved_destination_root
|
|
176
|
+
else
|
|
177
|
+
"#{resolved_destination_root}#{File::SEPARATOR}"
|
|
178
|
+
end
|
|
179
|
+
path.start_with?(root_prefix)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def managed_path_has_symlink?(path, destination_root)
|
|
183
|
+
current_path = path
|
|
184
|
+
until current_path == destination_root
|
|
185
|
+
return true if File.symlink?(current_path)
|
|
186
|
+
|
|
187
|
+
parent = File.dirname(current_path)
|
|
188
|
+
return false if parent == current_path
|
|
189
|
+
|
|
190
|
+
current_path = parent
|
|
191
|
+
end
|
|
192
|
+
false
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def path_entry_exists?(path)
|
|
196
|
+
File.exist?(path) || File.symlink?(path)
|
|
197
|
+
end
|
|
198
|
+
private_class_method :resolve, :within_destination_root?, :managed_path_has_symlink?, :path_entry_exists?
|
|
199
|
+
end
|
|
200
|
+
private_constant :WritePath
|
|
201
|
+
|
|
53
202
|
# Encapsulates a single install run against one app root.
|
|
54
203
|
class Installer
|
|
55
204
|
def initialize(destination_root, skip_existing: false)
|
|
56
205
|
@destination_root = File.expand_path(destination_root.to_s)
|
|
206
|
+
@resolved_destination_root = WritePath.resolve_root(@destination_root)
|
|
57
207
|
@skip_existing = skip_existing
|
|
58
208
|
end
|
|
59
209
|
|
|
60
210
|
def install
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
actions
|
|
64
|
-
actions
|
|
65
|
-
actions.compact
|
|
211
|
+
copy_write_paths = validate_copy_paths_before_copy
|
|
212
|
+
settings_write_path = validate_settings_before_copy
|
|
213
|
+
actions = FILES.map { |source, dest_rel| copy_file(source, dest_rel, copy_write_paths.fetch(dest_rel)) }
|
|
214
|
+
actions.push(register_hook(settings_write_path), remove_legacy_hook).compact
|
|
66
215
|
end
|
|
67
216
|
|
|
68
217
|
private
|
|
69
218
|
|
|
70
|
-
attr_reader :destination_root, :skip_existing
|
|
219
|
+
attr_reader :destination_root, :resolved_destination_root, :skip_existing
|
|
71
220
|
|
|
72
221
|
def validate_settings_before_copy
|
|
73
222
|
settings_path = File.join(destination_root, SETTINGS_REL)
|
|
74
|
-
|
|
223
|
+
if skip_existing && path_entry_exists?(settings_path)
|
|
224
|
+
raise Error, "#{SETTINGS_REL} is a dangling symlink, so it cannot be skipped safely" unless
|
|
225
|
+
File.exist?(settings_path)
|
|
226
|
+
|
|
227
|
+
return settings_path
|
|
228
|
+
end
|
|
75
229
|
|
|
230
|
+
settings_write_path = write_path_for(settings_path)
|
|
76
231
|
read_settings(settings_path)
|
|
232
|
+
settings_write_path
|
|
77
233
|
end
|
|
78
234
|
|
|
79
|
-
def
|
|
235
|
+
def validate_copy_paths_before_copy
|
|
236
|
+
FILES.values.to_h do |dest_rel|
|
|
237
|
+
dest_path = File.join(destination_root, dest_rel)
|
|
238
|
+
if skip_existing && path_entry_exists?(dest_path)
|
|
239
|
+
# A missing skipped hook would still be registered; a missing skill remains intentionally skipped.
|
|
240
|
+
raise Error, "#{HOOK_REL} is a dangling symlink, so its missing target cannot be skipped safely" if
|
|
241
|
+
dest_rel == HOOK_REL && !File.exist?(dest_path)
|
|
242
|
+
|
|
243
|
+
next [dest_rel, dest_path]
|
|
244
|
+
end
|
|
245
|
+
[dest_rel, write_path_for(dest_path)]
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def copy_file(source, dest_rel, write_path)
|
|
80
250
|
source_path = File.join(TEMPLATES_DIR, source)
|
|
81
251
|
dest_path = File.join(destination_root, dest_rel)
|
|
82
|
-
|
|
252
|
+
hook = dest_rel == HOOK_REL
|
|
253
|
+
existed = path_entry_exists?(dest_path)
|
|
83
254
|
return "skipped #{dest_rel} (already exists)" if skip_existing && existed
|
|
84
255
|
|
|
85
256
|
new_content = File.read(source_path)
|
|
86
|
-
unchanged =
|
|
257
|
+
unchanged = File.exist?(dest_path) && File.read(dest_path) == new_content
|
|
258
|
+
write_strategy = nil
|
|
87
259
|
|
|
88
260
|
unless unchanged
|
|
89
|
-
|
|
90
|
-
|
|
261
|
+
# File.write previously required an existing guardrail target to be writable even when its parent
|
|
262
|
+
# directory could replace it. Preserve that protection before selecting the atomic replacement path.
|
|
263
|
+
FileWriter.ensure_existing_writable!(write_path)
|
|
264
|
+
|
|
265
|
+
FileUtils.mkdir_p(File.dirname(write_path))
|
|
266
|
+
write_strategy = FileWriter.write(
|
|
267
|
+
write_path,
|
|
268
|
+
new_content,
|
|
269
|
+
new_file_mode: 0o666 & ~File.umask,
|
|
270
|
+
allow_in_place_fallback: true,
|
|
271
|
+
ensure_executable: hook
|
|
272
|
+
)
|
|
91
273
|
end
|
|
92
|
-
|
|
274
|
+
FileWriter.finish_copy(write_path, ensure_executable: hook, strategy: write_strategy)
|
|
93
275
|
|
|
94
276
|
return "unchanged #{dest_rel}" if unchanged
|
|
95
277
|
|
|
96
278
|
existed ? "updated #{dest_rel}" : "created #{dest_rel}"
|
|
97
279
|
end
|
|
98
280
|
|
|
99
|
-
def register_hook
|
|
281
|
+
def register_hook(settings_write_path)
|
|
100
282
|
settings_path = File.join(destination_root, SETTINGS_REL)
|
|
101
|
-
return "skipped #{SETTINGS_REL} (already exists)" if skip_existing &&
|
|
283
|
+
return "skipped #{SETTINGS_REL} (already exists)" if skip_existing && path_entry_exists?(settings_path)
|
|
102
284
|
|
|
103
285
|
settings = read_settings(settings_path)
|
|
104
286
|
return "unchanged #{SETTINGS_REL} (hook already registered)" if hook_registered?(settings)
|
|
105
287
|
|
|
106
288
|
add_hook(settings)
|
|
107
|
-
FileUtils.mkdir_p(File.dirname(
|
|
108
|
-
existed =
|
|
109
|
-
|
|
289
|
+
FileUtils.mkdir_p(File.dirname(settings_write_path))
|
|
290
|
+
existed = path_entry_exists?(settings_path)
|
|
291
|
+
FileWriter.write(settings_write_path, "#{JSON.pretty_generate(settings)}\n")
|
|
110
292
|
existed ? "updated #{SETTINGS_REL} (registered hook)" : "created #{SETTINGS_REL} (registered hook)"
|
|
111
293
|
end
|
|
112
294
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
temp = Tempfile.create([".#{File.basename(path)}", ".tmp"], directory)
|
|
120
|
-
begin
|
|
121
|
-
temp.write(content)
|
|
122
|
-
temp.flush
|
|
123
|
-
temp.fsync
|
|
124
|
-
temp.close
|
|
125
|
-
File.chmod(mode, temp.path)
|
|
126
|
-
File.rename(temp.path, path)
|
|
127
|
-
rescue StandardError
|
|
128
|
-
FileUtils.rm_f(temp.path)
|
|
129
|
-
raise
|
|
130
|
-
end
|
|
295
|
+
def path_entry_exists?(path)
|
|
296
|
+
File.exist?(path) || File.symlink?(path)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def write_path_for(path)
|
|
300
|
+
WritePath.resolve_within(path, destination_root, resolved_destination_root)
|
|
131
301
|
end
|
|
132
302
|
|
|
133
303
|
def remove_legacy_hook
|
|
@@ -200,9 +370,7 @@ module ReactOnRails
|
|
|
200
370
|
def add_hook(settings)
|
|
201
371
|
hooks = (settings["hooks"] ||= {})
|
|
202
372
|
post_tool_use = (hooks["PostToolUse"] ||= [])
|
|
203
|
-
post_tool_use
|
|
204
|
-
Array(candidate["hooks"]).reject! { |hook| managed_hook?(hook) }
|
|
205
|
-
end
|
|
373
|
+
remove_managed_hooks(post_tool_use)
|
|
206
374
|
entry = post_tool_use.find { |candidate| candidate["matcher"] == HOOK_MATCHER }
|
|
207
375
|
unless entry
|
|
208
376
|
entry = { "matcher" => HOOK_MATCHER, "hooks" => [] }
|
|
@@ -211,6 +379,16 @@ module ReactOnRails
|
|
|
211
379
|
(entry["hooks"] ||= []) << { "type" => "command", "command" => HOOK_COMMAND, "args" => HOOK_ARGS }
|
|
212
380
|
end
|
|
213
381
|
|
|
382
|
+
def remove_managed_hooks(post_tool_use)
|
|
383
|
+
post_tool_use.reject! do |candidate|
|
|
384
|
+
candidate_hooks = candidate["hooks"]
|
|
385
|
+
next false unless candidate_hooks
|
|
386
|
+
|
|
387
|
+
removed_hooks = candidate_hooks.reject! { |hook| managed_hook?(hook) }
|
|
388
|
+
removed_hooks && candidate_hooks.empty?
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
|
|
214
392
|
def registered_hook?(hook)
|
|
215
393
|
hook["type"] == "command" && hook["command"] == HOOK_COMMAND && hook["args"] == HOOK_ARGS
|
|
216
394
|
end
|
|
@@ -84,6 +84,20 @@ module ReactOnRails
|
|
|
84
84
|
exit 1
|
|
85
85
|
end
|
|
86
86
|
|
|
87
|
+
# Replace this process with an available executable using the same
|
|
88
|
+
# Bundler-aware lookup strategy as development-process startup.
|
|
89
|
+
def exec_process_if_available(process, args)
|
|
90
|
+
return exec(process, *args) if installed?(process)
|
|
91
|
+
return false unless process_available_in_system?(process)
|
|
92
|
+
|
|
93
|
+
env_overrides = preserve_runtime_env_vars
|
|
94
|
+
with_unbundled_context do
|
|
95
|
+
exec(env_overrides, process, *args)
|
|
96
|
+
end
|
|
97
|
+
rescue Errno::ENOENT
|
|
98
|
+
false
|
|
99
|
+
end
|
|
100
|
+
|
|
87
101
|
private
|
|
88
102
|
|
|
89
103
|
# Check if a process is actually usable in the current execution context
|