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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +1 -1
  3. data/Gemfile.development_dependencies +2 -2
  4. data/Gemfile.lock +65 -73
  5. data/Steepfile +1 -0
  6. data/docs/agent/install-and-upgrade.md +8 -3
  7. data/docs/agent/rsc-adoption.md +3 -0
  8. data/lib/generators/react_on_rails/base_generator.rb +2 -1
  9. data/lib/generators/react_on_rails/generated_webpack_config_pair.rb +84 -0
  10. data/lib/generators/react_on_rails/generator_helper.rb +2 -4
  11. data/lib/generators/react_on_rails/generator_messages.rb +1 -1
  12. data/lib/generators/react_on_rails/install_generator.rb +2 -1
  13. data/lib/generators/react_on_rails/js_dependency_manager.rb +14 -14
  14. data/lib/generators/react_on_rails/pro/USAGE +14 -3
  15. data/lib/generators/react_on_rails/pro_generator.rb +1 -1
  16. data/lib/generators/react_on_rails/pro_setup.rb +41 -213
  17. data/lib/generators/react_on_rails/rsc_setup.rb +7 -16
  18. data/lib/generators/react_on_rails/templates/pro/base/config/initializers/react_on_rails_pro.rb.tt +1 -1
  19. data/lib/react_on_rails/agent_guardrails.rb +217 -39
  20. data/lib/react_on_rails/dev/process_manager.rb +14 -0
  21. data/lib/react_on_rails/dev/server_manager.rb +1727 -103
  22. data/lib/react_on_rails/doctor.rb +875 -44
  23. data/lib/react_on_rails/helper.rb +3 -3
  24. data/lib/react_on_rails/react_component/render_options.rb +5 -7
  25. data/lib/react_on_rails/server_rendering_pool/ruby_embedded_java_script.rb +27 -116
  26. data/lib/react_on_rails/utils.rb +65 -0
  27. data/lib/react_on_rails/version.rb +1 -1
  28. data/lib/react_on_rails/version_checker/lockfile_resolution.rb +170 -0
  29. data/lib/react_on_rails/version_checker.rb +43 -22
  30. data/rakelib/run_rspec.rake +6 -1
  31. data/sig/react_on_rails/configuration.rbs +28 -26
  32. data/sig/react_on_rails/controller.rbs +2 -1
  33. data/sig/react_on_rails/dev/process_manager.rbs +1 -0
  34. data/sig/react_on_rails/dev/server_manager.rbs +150 -10
  35. data/sig/react_on_rails/git_utils.rbs +5 -1
  36. data/sig/react_on_rails/locales.rbs +4 -4
  37. data/sig/react_on_rails/prerender_error.rbs +2 -2
  38. data/sig/react_on_rails/test_helper.rbs +15 -1
  39. data/sig/react_on_rails/utils.rbs +2 -2
  40. data/sig/react_on_rails/version_checker/lockfile_resolution.rbs +31 -0
  41. data/sig/react_on_rails/version_checker.rbs +30 -7
  42. 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
- validate_settings_before_copy
62
- actions = FILES.map { |source, dest_rel| copy_file(source, dest_rel) }
63
- actions << register_hook
64
- actions << remove_legacy_hook
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
- return if skip_existing && File.exist?(settings_path)
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 copy_file(source, dest_rel)
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
- existed = File.exist?(dest_path)
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 = existed && File.read(dest_path) == new_content
257
+ unchanged = File.exist?(dest_path) && File.read(dest_path) == new_content
258
+ write_strategy = nil
87
259
 
88
260
  unless unchanged
89
- FileUtils.mkdir_p(File.dirname(dest_path))
90
- File.write(dest_path, new_content)
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
- File.chmod(0o755, dest_path) if dest_rel == HOOK_REL
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 && File.exist?(settings_path)
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(settings_path))
108
- existed = File.exist?(settings_path)
109
- atomic_write(settings_path, "#{JSON.pretty_generate(settings)}\n")
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
- # settings.json is the user's file and may hold unrelated configuration, so it is replaced by
114
- # rename rather than truncate-and-write: an interrupted or failed write can never leave a
115
- # half-written or empty settings file behind.
116
- def atomic_write(path, content)
117
- directory = File.dirname(path)
118
- mode = File.exist?(path) ? File.stat(path).mode & 0o7777 : 0o644
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.each do |candidate|
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