rooibos 0.5.0 → 0.6.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 (169) hide show
  1. checksums.yaml +4 -4
  2. data/.builds/ruby-3.2.yml +9 -5
  3. data/.builds/ruby-3.3.yml +9 -5
  4. data/.builds/ruby-3.4.yml +9 -5
  5. data/.builds/ruby-4.0.0.yml +9 -5
  6. data/AGENTS.md +1 -1
  7. data/CHANGELOG.md +46 -0
  8. data/README.md +2 -2
  9. data/README.rdoc +374 -0
  10. data/REUSE.toml +5 -0
  11. data/Rakefile +1 -1
  12. data/doc/best_practices/forms_and_validation.md +20 -0
  13. data/doc/best_practices/http_workflows.md +20 -0
  14. data/doc/best_practices/index.md +26 -0
  15. data/doc/best_practices/lists_and_tables.md +20 -0
  16. data/doc/best_practices/modal_dialogs.md +20 -0
  17. data/doc/best_practices/no_stateful_widgets.md +184 -0
  18. data/doc/best_practices/orchestration.md +20 -0
  19. data/doc/best_practices/streaming_data.md +20 -0
  20. data/doc/contributors/design/commands_and_outlets.md +1 -1
  21. data/doc/contributors/documentation_plan.md +616 -0
  22. data/doc/contributors/documentation_stub_audit.md +112 -0
  23. data/doc/contributors/documentation_style.md +275 -0
  24. data/doc/contributors/e2e_pty.md +168 -0
  25. data/doc/contributors/specs/earliest_tutorial_steps_per_story.md +70 -0
  26. data/doc/contributors/specs/file_browser.md +789 -0
  27. data/doc/contributors/specs/file_browser_stories.md +774 -0
  28. data/doc/contributors/specs/tutorials_to_stories.rb +167 -0
  29. data/doc/contributors/todo/scrollbar.md +118 -0
  30. data/doc/contributors/tutorial_old/01_project_setup.md +20 -0
  31. data/doc/contributors/tutorial_old/02_hello_world.md +24 -0
  32. data/doc/contributors/tutorial_old/03_adding_state.md +26 -0
  33. data/doc/contributors/tutorial_old/06_organizing_your_code.md +20 -0
  34. data/doc/contributors/tutorial_old/07_your_first_command.md +21 -0
  35. data/doc/contributors/tutorial_old/08_the_preview_pane.md +20 -0
  36. data/doc/contributors/tutorial_old/09_loading_states.md +20 -0
  37. data/doc/contributors/tutorial_old/10_testing_your_app.md +20 -0
  38. data/doc/contributors/tutorial_old/11_polish_and_refine.md +20 -0
  39. data/doc/contributors/tutorial_old/12_going_further.md +20 -0
  40. data/doc/contributors/tutorial_old/index.md +20 -0
  41. data/doc/essentials/commands.md +20 -0
  42. data/doc/essentials/index.md +31 -0
  43. data/doc/essentials/messages.md +21 -0
  44. data/doc/essentials/models.md +21 -0
  45. data/doc/essentials/shortcuts.md +19 -0
  46. data/doc/essentials/the_elm_architecture.md +24 -0
  47. data/doc/essentials/the_runtime.md +21 -0
  48. data/doc/essentials/update_functions.md +20 -0
  49. data/doc/essentials/views.md +22 -0
  50. data/doc/getting_started/for_go_developers.md +16 -0
  51. data/doc/getting_started/for_python_developers.md +16 -0
  52. data/doc/getting_started/for_react_developers.md +17 -0
  53. data/doc/getting_started/index.md +52 -0
  54. data/doc/getting_started/install.md +20 -0
  55. data/doc/getting_started/quickstart.md +9 -45
  56. data/doc/getting_started/ruby_primer.md +19 -0
  57. data/doc/getting_started/why_rooibos.md +20 -0
  58. data/doc/index.md +79 -11
  59. data/doc/scaling_up/async_patterns.md +20 -0
  60. data/doc/scaling_up/command_composition.md +20 -0
  61. data/doc/scaling_up/custom_commands.md +21 -0
  62. data/doc/scaling_up/fractal_architecture.md +20 -0
  63. data/doc/scaling_up/index.md +30 -0
  64. data/doc/scaling_up/message_routing.md +20 -0
  65. data/doc/scaling_up/ractor_safety.md +20 -0
  66. data/doc/scaling_up/testing.md +21 -0
  67. data/doc/troubleshooting/common_errors.md +20 -0
  68. data/doc/troubleshooting/debugging.md +21 -0
  69. data/doc/troubleshooting/index.md +23 -0
  70. data/doc/troubleshooting/performance.md +20 -0
  71. data/doc/tutorial/01_project_setup.md +44 -0
  72. data/doc/tutorial/02_hello_world.md +45 -0
  73. data/doc/tutorial/03_static_file_list.md +44 -0
  74. data/doc/tutorial/04_arrow_navigation.md +47 -0
  75. data/doc/tutorial/05_real_files.md +45 -0
  76. data/doc/tutorial/06_safe_refactoring.md +21 -0
  77. data/doc/tutorial/07_red_first_tdd.md +26 -0
  78. data/doc/tutorial/08_file_metadata.md +42 -0
  79. data/doc/tutorial/09_text_preview.md +44 -0
  80. data/doc/tutorial/10_directory_tree.md +42 -0
  81. data/doc/tutorial/11_pane_focus.md +40 -0
  82. data/doc/tutorial/12_sorting.md +41 -0
  83. data/doc/tutorial/13_filtering.md +43 -0
  84. data/doc/tutorial/14_toggle_hidden.md +41 -0
  85. data/doc/tutorial/15_text_input_widget.md +43 -0
  86. data/doc/tutorial/16_rename_files.md +42 -0
  87. data/doc/tutorial/17_confirmation_dialogs.md +43 -0
  88. data/doc/tutorial/18_progress_indicators.md +43 -0
  89. data/doc/tutorial/19_atomic_operations.md +42 -0
  90. data/doc/tutorial/20_external_editor.md +42 -0
  91. data/doc/tutorial/21_modal_overlays.md +41 -0
  92. data/doc/tutorial/22_error_handling.md +43 -0
  93. data/doc/tutorial/23_terminal_capabilities.md +53 -0
  94. data/doc/tutorial/24_mouse_events.md +43 -0
  95. data/doc/tutorial/25_resize_events.md +43 -0
  96. data/doc/tutorial/26_loading_states.md +42 -0
  97. data/doc/tutorial/27_performance.md +43 -0
  98. data/doc/tutorial/28_color_schemes.md +47 -0
  99. data/doc/tutorial/29_configuration.md +124 -0
  100. data/doc/tutorial/30_going_further.md +17 -0
  101. data/doc/tutorial/index.md +17 -0
  102. data/examples/app_file_browser/app.rb +40 -0
  103. data/examples/app_fractal_dashboard/dashboard/update_manual.rb +7 -7
  104. data/examples/app_fractal_dashboard/fragments/custom_shell_input.rb +5 -5
  105. data/examples/app_fractal_dashboard/fragments/custom_shell_modal.rb +1 -1
  106. data/examples/app_fractal_dashboard/fragments/disk_usage.rb +2 -2
  107. data/examples/app_fractal_dashboard/fragments/network_panel.rb +4 -4
  108. data/examples/app_fractal_dashboard/fragments/ping.rb +2 -2
  109. data/examples/app_fractal_dashboard/fragments/stats_panel.rb +4 -4
  110. data/examples/app_fractal_dashboard/fragments/system_info.rb +2 -2
  111. data/examples/app_fractal_dashboard/fragments/uptime.rb +2 -2
  112. data/examples/verify_website_first_app/app.rb +85 -0
  113. data/examples/verify_website_hello_mvu/app.rb +31 -0
  114. data/examples/widget_command_system/app.rb +15 -13
  115. data/exe/rooibos +10 -0
  116. data/generate_tutorial_stubs.rb +126 -0
  117. data/lib/rooibos/cli/commands/new.rb +373 -0
  118. data/lib/rooibos/cli/commands/run.rb +98 -0
  119. data/lib/rooibos/cli.rb +78 -0
  120. data/lib/rooibos/command/all.rb +25 -20
  121. data/lib/rooibos/command/batch.rb +26 -25
  122. data/lib/rooibos/command/custom.rb +84 -1
  123. data/lib/rooibos/command/http.rb +59 -55
  124. data/lib/rooibos/command/lifecycle.rb +5 -5
  125. data/lib/rooibos/command/open.rb +86 -0
  126. data/lib/rooibos/command/outlet.rb +105 -3
  127. data/lib/rooibos/command/wait.rb +5 -5
  128. data/lib/rooibos/command.rb +57 -74
  129. data/lib/rooibos/message/batch.rb +39 -0
  130. data/lib/rooibos/message/canceled.rb +51 -0
  131. data/lib/rooibos/message/error.rb +48 -0
  132. data/lib/rooibos/message/open.rb +30 -0
  133. data/lib/rooibos/message.rb +84 -4
  134. data/lib/rooibos/router.rb +11 -14
  135. data/lib/rooibos/runtime.rb +40 -43
  136. data/lib/rooibos/shortcuts.rb +47 -0
  137. data/lib/rooibos/test_helper.rb +71 -6
  138. data/lib/rooibos/version.rb +1 -1
  139. data/lib/rooibos/welcome.rb +237 -0
  140. data/lib/rooibos.rb +4 -3
  141. data/mise.toml +1 -1
  142. data/rbs_collection.lock.yaml +2 -2
  143. data/sig/concurrent.rbs +3 -0
  144. data/sig/gem.rbs +20 -0
  145. data/sig/rooibos/cli.rbs +42 -0
  146. data/sig/rooibos/command.rbs +48 -0
  147. data/sig/rooibos/message.rbs +60 -0
  148. data/sig/rooibos/shortcuts.rbs +14 -0
  149. data/sig/rooibos/test_helper.rbs +6 -2
  150. data/sig/rooibos/welcome.rbs +75 -0
  151. data/tasks/install.rake +29 -0
  152. data/tasks/resources/build.yml.erb +2 -0
  153. metadata +272 -38
  154. data/doc/concepts/application_architecture.md +0 -197
  155. data/doc/concepts/application_testing.md +0 -49
  156. data/doc/concepts/async_work.md +0 -164
  157. data/doc/concepts/commands.md +0 -530
  158. data/doc/concepts/message_processing.md +0 -51
  159. data/doc/contributors/WIP/decomposition_strategies_analysis.md +0 -258
  160. data/doc/contributors/WIP/implementation_plan.md +0 -409
  161. data/doc/contributors/WIP/init_callable_proposal.md +0 -344
  162. data/doc/contributors/WIP/runtime_refactoring_status.md +0 -47
  163. data/doc/contributors/WIP/task.md +0 -36
  164. data/doc/contributors/WIP/v0.4.0_todo.md +0 -468
  165. data/doc/contributors/kit-no-outlet.md +0 -238
  166. data/doc/contributors/priorities.md +0 -38
  167. data/doc/images/.gitkeep +0 -0
  168. data/exe/.gitkeep +0 -0
  169. /data/doc/contributors/{WIP → design}/mvu_tea_implementations_research.md +0 -0
@@ -0,0 +1,373 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: LGPL-3.0-or-later
6
+ #++
7
+
8
+ require "open3"
9
+ require "fileutils"
10
+ require "pathname"
11
+ require "optparse"
12
+
13
+ module Rooibos
14
+ module CLI
15
+ module Commands # :nodoc:
16
+ # Scaffolds a new Rooibos TUI application.
17
+ #
18
+ # Starting a TUI project from scratch is tedious. Gem structure,
19
+ # test setup, executable wiring, and dependency management all
20
+ # take time before you write your first line of application code.
21
+ #
22
+ # This command delegates to +bundle gem+ for the boilerplate, then
23
+ # customizes the result for Rooibos. It creates a working Model-View-Update
24
+ # skeleton with a passing test.
25
+ #
26
+ # Use it to bootstrap new projects.
27
+ #
28
+ # === Example
29
+ #
30
+ # rooibos new my_app
31
+ # rooibos new my_app --no-git
32
+ # rooibos new my_app --test=rspec # warns about TestHelper
33
+ module New
34
+ # Default flags for bundle gem.
35
+ # Note: We use --no-bundle so we can add rooibos to Gemfile first.
36
+ BUNDLE_GEM_DEFAULTS = %w[
37
+ --exe
38
+ --no-coc
39
+ --changelog
40
+ --no-ext
41
+ --git
42
+ --no-mit
43
+ --test=minitest
44
+ --no-ci
45
+ --linter=rubocop
46
+ --no-bundle
47
+ ].freeze
48
+
49
+ # Runs the new command.
50
+ #
51
+ # [argv] Command-line arguments (expects app name as first element).
52
+ def self.call(argv)
53
+ options = parse_options(argv)
54
+
55
+ if options[:help]
56
+ puts usage
57
+ exit(0)
58
+ end
59
+
60
+ if argv.empty?
61
+ warn "Error: Missing application name"
62
+ warn usage
63
+ exit(1)
64
+ end
65
+
66
+ app_name = argv.shift
67
+ passthrough_args = argv # Remaining args passed to bundle gem
68
+
69
+ create_app(app_name, passthrough_args, options)
70
+ end
71
+
72
+ # Returns command-specific usage.
73
+ def self.usage
74
+ <<~USAGE
75
+ Usage: rooibos new <appname> [options]
76
+
77
+ Creates a new Rooibos TUI application using `bundle gem`.
78
+
79
+ Arguments:
80
+ <appname> Name of the application to create
81
+
82
+ Options:
83
+ --help, -h Show this help
84
+
85
+ Bundle Gem Defaults (can be overridden):
86
+ --exe Create executable (enabled by default)
87
+ --no-coc No Code of Conduct (default)
88
+ --changelog Generate CHANGELOG.md (default)
89
+ --no-ext No native extension (default)
90
+ --git Initialize git repo (default)
91
+ --no-mit No MIT license (default)
92
+ --test=minitest Use Minitest (default, recommended)
93
+ --no-ci No CI config (default)
94
+ --linter=rubocop Use RuboCop (default)
95
+ --bundle Run bundle install (default)
96
+
97
+ Any bundle gem option can be passed through, for example:
98
+ rooibos new my_app --no-git
99
+ rooibos new my_app --test=rspec # warns about TestHelper
100
+
101
+ Note: Rooibos::TestHelper is only verified to work with Minitest.
102
+ USAGE
103
+ end
104
+
105
+ def self.parse_options(argv)
106
+ options = { help: false, test_framework: "minitest", skip_bundle: false }
107
+
108
+ # Extract --help before OptionParser to avoid conflicts with passthrough
109
+ if argv.include?("--help") || argv.include?("-h")
110
+ argv.delete("--help")
111
+ argv.delete("-h")
112
+ options[:help] = true
113
+ end
114
+
115
+ # Detect test framework from passthrough args
116
+ argv.each do |arg|
117
+ if arg.start_with?("--test=")
118
+ options[:test_framework] = arg.sub("--test=", "")
119
+ end
120
+ end
121
+
122
+ # Detect if user wants to skip bundle install
123
+ if argv.include?("--no-bundle")
124
+ options[:skip_bundle] = true
125
+ end
126
+
127
+ options
128
+ end
129
+ private_class_method :parse_options
130
+
131
+ def self.create_app(app_name, passthrough_args, options)
132
+ puts "Creating new Rooibos application: #{app_name}"
133
+
134
+ # Warn about non-minitest frameworks
135
+ test_framework = options[:test_framework].to_s
136
+ if %w[rspec test-unit].include?(test_framework)
137
+ warn "Warning: Rooibos::TestHelper has not been verified to work with #{test_framework}."
138
+ warn " You may need to adapt the test helpers for your framework."
139
+ end
140
+
141
+ # Build bundle gem command
142
+ bundle_args = build_bundle_gem_args(passthrough_args)
143
+ cmd = ["bundle", "gem", app_name] + bundle_args
144
+
145
+ puts "Running: #{cmd.join(' ')}"
146
+ stdout, stderr, status = Open3.capture3(*cmd)
147
+ unless status.success?
148
+ warn "Error running bundle gem:"
149
+ warn stderr
150
+ exit(1)
151
+ end
152
+ puts stdout
153
+
154
+ # Determine the normalized gem name by looking at lib/
155
+ app_path = Pathname.new(app_name)
156
+ lib_path = app_path / "lib"
157
+ lib_files = Dir.glob(lib_path / "*.rb")
158
+ gem_name = lib_files.map { |f| File.basename(f, ".rb") }
159
+ .reject { |n| n.end_with?("_version") }
160
+ .first
161
+
162
+ unless gem_name
163
+ warn "Could not determine gem name from lib/ directory"
164
+ exit(1)
165
+ end
166
+
167
+ module_name = to_module_name(gem_name)
168
+
169
+ # Overwrite lib/<gem_name>.rb with Rooibos template
170
+ lib_file = lib_path / "#{gem_name}.rb"
171
+ File.write(lib_file.to_s, app_template(gem_name, module_name))
172
+ puts "Updated #{lib_file}"
173
+
174
+ # Update the bundler-created executable to call Rooibos.run
175
+ exe_file = app_path / "exe" / gem_name
176
+ if exe_file.exist?
177
+ File.write(exe_file.to_s, exe_template(gem_name, module_name))
178
+ puts "Updated #{exe_file}"
179
+ end
180
+
181
+ # Add rooibos as runtime dependency to gemspec (not Gemfile) for dev/prod parity
182
+ gemspec_files = Dir.glob(app_path / "*.gemspec")
183
+ if gemspec_files.any?
184
+ gemspec_file = gemspec_files.first
185
+ content = File.read(gemspec_file)
186
+
187
+ # Comment out placeholder lines that RubyGems 3.x validates
188
+ content = fix_gemspec_placeholders(content)
189
+
190
+ # Check if already has rooibos dependency
191
+ unless content.match?(/add_(?:runtime_)?dependency.*rooibos/)
192
+ # Use Gem::Version to handle prerelease versions correctly
193
+ segments = Gem::Version.new(Rooibos::VERSION).segments
194
+ minor_version = segments.first(2).map(&:to_s).join(".")
195
+ # Insert before the final 'end' of the Gem::Specification block
196
+ content = content.sub(
197
+ /^end\s*\z/m,
198
+ "\n # https://www.rooibos.run\n spec.add_runtime_dependency \"rooibos\", \"~> #{minor_version}\"\nend\n"
199
+ )
200
+ end
201
+
202
+ File.write(gemspec_file, content)
203
+ puts "Updated #{gemspec_file}"
204
+ end
205
+
206
+ # Run bundle install unless user passed --no-bundle
207
+ unless options[:skip_bundle]
208
+ Dir.chdir(app_path) do
209
+ puts "Running bundle install..."
210
+ system("bundle", "install")
211
+ end
212
+ end
213
+
214
+ # Update test infrastructure for Rooibos
215
+ if test_framework == "minitest"
216
+ # Add rooibos/test_helper to the project's test_helper.rb
217
+ test_helper = app_path / "test" / "test_helper.rb"
218
+ if test_helper.exist?
219
+ content = File.read(test_helper.to_s)
220
+ unless content.include?("rooibos/test_helper")
221
+ # Add after the gem require line
222
+ content = content.sub(
223
+ /require\s+["']#{gem_name}["']/,
224
+ "\\0\nrequire \"rooibos/test_helper\""
225
+ )
226
+ File.write(test_helper.to_s, content)
227
+ puts "Updated #{test_helper}"
228
+ end
229
+ end
230
+
231
+ # Replace test file with real Rooibos test
232
+ test_file = app_path / "test" / "test_#{gem_name}.rb"
233
+ if test_file.exist?
234
+ File.write(test_file.to_s, test_template(gem_name, module_name))
235
+ puts "Updated #{test_file}"
236
+ end
237
+ end
238
+
239
+ # Make initial git commit if git is enabled and bundle didn't
240
+ if git_enabled?(passthrough_args)
241
+ make_initial_commit(app_path)
242
+ end
243
+
244
+ puts "\nDone! Your Rooibos application is ready."
245
+ puts " cd #{app_name}"
246
+ puts " rooibos run"
247
+ end
248
+ private_class_method :create_app
249
+
250
+ def self.build_bundle_gem_args(passthrough_args)
251
+ # Start with defaults
252
+ args = BUNDLE_GEM_DEFAULTS.dup
253
+
254
+ # Override with user-provided args (later args win in bundle gem)
255
+ args + passthrough_args
256
+ end
257
+ private_class_method :build_bundle_gem_args
258
+
259
+ def self.git_enabled?(passthrough_args)
260
+ # Git is enabled by default unless --no-git is passed
261
+ !passthrough_args.include?("--no-git")
262
+ end
263
+ private_class_method :git_enabled?
264
+
265
+ def self.make_initial_commit(app_path)
266
+ Dir.chdir(app_path) do
267
+ # Check if there are uncommitted changes
268
+ _, _, status = Open3.capture3("git", "status", "--porcelain")
269
+ return unless status.success?
270
+
271
+ uncommitted, = Open3.capture3("git", "status", "--porcelain")
272
+ return if uncommitted.strip.empty?
273
+
274
+ # Stage and commit
275
+ system("git", "add", "-A", out: File::NULL, err: File::NULL)
276
+ system("git", "commit", "-m", "Hello, Rooibos!\n\nhttps://www.rooibos.run",
277
+ out: File::NULL, err: File::NULL)
278
+ puts "Created initial git commit"
279
+ end
280
+ end
281
+ private_class_method :make_initial_commit
282
+
283
+ def self.to_module_name(gem_name)
284
+ gem_name.split(/[-_]/).map(&:capitalize).join
285
+ end
286
+ private_class_method :to_module_name
287
+
288
+ # Fixes gemspec placeholder lines that RubyGems 3.x+ rejects.
289
+ # Required fields get valid placeholder values; optional metadata block is deleted.
290
+ def self.fix_gemspec_placeholders(content)
291
+ result = content
292
+
293
+ # Replace required field TODOs with valid placeholders
294
+ result = result.gsub(
295
+ /^(\s*spec\.summary\s*=\s*)"TODO:[^"]*"/,
296
+ '\1"A Rooibos TUI application"'
297
+ )
298
+ result = result.gsub(
299
+ /^(\s*spec\.description\s*=\s*)"TODO:[^"]*"/,
300
+ '\1"A terminal user interface application built with Rooibos"'
301
+ )
302
+ result = result.gsub(
303
+ /^(\s*spec\.homepage\s*=\s*)"TODO:[^"]*"/,
304
+ '\1"https://www.rooibos.run"'
305
+ )
306
+ result = result.gsub(
307
+ /^(\s*spec\.authors\s*=\s*)\["TODO:[^\]]*"\]/,
308
+ '\1["Author"]'
309
+ )
310
+ result = result.gsub(
311
+ /^(\s*spec\.email\s*=\s*)\["TODO:[^\]]*"\]/,
312
+ '\1["author@example.com"]'
313
+ )
314
+
315
+ # Delete the entire metadata block (optional and causes validation issues)
316
+ result.gsub(/^\s*spec\.metadata\["[^"]*"\]\s*=.*\n/, "")
317
+ end
318
+ private_class_method :fix_gemspec_placeholders
319
+
320
+ def self.exe_template(gem_name, module_name)
321
+ <<~RUBY
322
+ #!/usr/bin/env ruby
323
+ # frozen_string_literal: true
324
+
325
+ require "#{gem_name}"
326
+ Rooibos.run(#{module_name})
327
+ RUBY
328
+ end
329
+ private_class_method :exe_template
330
+
331
+ def self.app_template(gem_name, module_name)
332
+ <<~RUBY
333
+ # frozen_string_literal: true
334
+
335
+ require "rooibos"
336
+ require "rooibos/welcome"
337
+
338
+ # To get started, replace the following lines with your own code
339
+ # and remove the `require "rooibos/welcome"` line from above
340
+ module #{module_name}
341
+ Model = Rooibos::Welcome::Model
342
+ View = Rooibos::Welcome::View
343
+ Update = Rooibos::Welcome::Update
344
+ Init = Rooibos::Welcome::Init
345
+ end
346
+ RUBY
347
+ end
348
+ private_class_method :app_template
349
+
350
+ def self.test_template(gem_name, module_name)
351
+ <<~RUBY
352
+ # frozen_string_literal: true
353
+
354
+ require "test_helper"
355
+
356
+ class Test#{module_name} < Minitest::Test
357
+ include Rooibos::TestHelper
358
+
359
+ def test_it_exits_with_ctrl_c
360
+ with_test_terminal do
361
+ inject_key(:ctrl_c)
362
+ Rooibos.run(#{module_name})
363
+ assert true, "Should reach this point without hanging."
364
+ end
365
+ end
366
+ end
367
+ RUBY
368
+ end
369
+ private_class_method :test_template
370
+ end
371
+ end
372
+ end
373
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: LGPL-3.0-or-later
6
+ #++
7
+
8
+ require "pathname"
9
+ require "rbconfig"
10
+
11
+ module Rooibos
12
+ module CLI
13
+ module Commands
14
+ # Runs the Rooibos application in the current directory.
15
+ #
16
+ # Developers switch between editor and terminal constantly. Typing
17
+ # +bundle exec exe/my_app+ every time is tedious and error-prone.
18
+ #
19
+ # This command finds the executable in +exe/+ and runs it via bundler.
20
+ # It walks up the directory tree to find the project root, then
21
+ # executes the first executable it finds.
22
+ #
23
+ # Use it from any directory within your project.
24
+ #
25
+ # === Example
26
+ #
27
+ # rooibos run
28
+ module Run
29
+ # Runs the run command.
30
+ #
31
+ # [argv] Command-line arguments.
32
+ def self.call(argv)
33
+ if ["--help", "-h"].include?(argv.first)
34
+ puts usage
35
+ exit(0)
36
+ end
37
+
38
+ run_app
39
+ end
40
+
41
+ # Returns command-specific usage.
42
+ def self.usage # :nodoc:
43
+ <<~USAGE
44
+ Usage: rooibos run [options]
45
+
46
+ Runs the Rooibos application in the current directory.
47
+
48
+ Options:
49
+ --help, -h Show this help
50
+ USAGE
51
+ end
52
+ private_class_method :usage
53
+
54
+ def self.run_app # :nodoc:
55
+ project_root = find_project_root
56
+ unless project_root
57
+ warn "Error: Not in a Rooibos project directory"
58
+ warn "Could not find a .gemspec or Gemfile"
59
+ exit(1)
60
+ end
61
+
62
+ exe_dir = project_root / "exe"
63
+ unless exe_dir.directory?
64
+ warn "Error: No exe/ directory found"
65
+ exit(1)
66
+ end
67
+
68
+ executables = Dir.glob(exe_dir / "*").select { |f| File.executable?(f) }
69
+ if executables.empty?
70
+ warn "Error: No executable found in exe/"
71
+ exit(1)
72
+ end
73
+
74
+ executable = executables.first
75
+ puts "Running #{File.basename(executable)}..."
76
+
77
+ # Run with bundler/setup for gem activation, but skip bundle exec CLI
78
+ # to produce clean stack traces without bundler/thor frames
79
+ Dir.chdir(project_root)
80
+ exec(RbConfig.ruby, "-rbundler/setup", executable)
81
+ end
82
+ private_class_method :run_app
83
+
84
+ def self.find_project_root # :nodoc:
85
+ current = Pathname.pwd
86
+ loop do
87
+ return current if (current / "Gemfile").exist?
88
+ return current if Dir.glob(current / "*.gemspec").any?
89
+ parent = current.parent
90
+ return nil if parent == current
91
+ current = parent
92
+ end
93
+ end
94
+ private_class_method :find_project_root
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: LGPL-3.0-or-later
6
+ #++
7
+
8
+ require_relative "version"
9
+ require_relative "cli/commands/new"
10
+ require_relative "cli/commands/run"
11
+
12
+ module Rooibos
13
+ # Entry point for the Rooibos command-line interface.
14
+ #
15
+ # Rooibos provides a CLI for common development tasks. Rather than
16
+ # remembering incantations for each tool, use a single command.
17
+ #
18
+ # This module dispatches to subcommands. It routes <tt>new</tt> to
19
+ # project scaffolding and <tt>run</tt> to application execution.
20
+ #
21
+ # Use it via the +rooibos+ executable.
22
+ #
23
+ # === Example
24
+ #
25
+ # # From terminal:
26
+ # rooibos new my_app
27
+ # cd my_app
28
+ # rooibos run
29
+ #
30
+ # # Programmatic access:
31
+ # Rooibos::CLI.call(["new", "my_app"])
32
+ # Rooibos::CLI.call(["run"])
33
+ module CLI
34
+ # Maps command names to handler modules.
35
+ COMMANDS = { # :nodoc:
36
+ "new" => Commands::New,
37
+ "run" => Commands::Run,
38
+ }.freeze
39
+
40
+ # Entry point for the CLI.
41
+ #
42
+ # [argv] Command-line arguments array.
43
+ def self.call(argv)
44
+ command_name = argv.shift
45
+
46
+ case command_name
47
+ when "--version", "-v"
48
+ puts "Rooibos #{Rooibos::VERSION}"
49
+ when "--help", "-h", nil
50
+ puts usage
51
+ else
52
+ command = COMMANDS[command_name]
53
+ if command
54
+ command.call(argv)
55
+ else
56
+ warn "Unknown command: #{command_name}"
57
+ warn usage
58
+ exit(1)
59
+ end
60
+ end
61
+ end
62
+
63
+ # Returns the main usage message.
64
+ def self.usage
65
+ <<~USAGE
66
+ Usage: rooibos <command> [options]
67
+
68
+ Commands:
69
+ new <appname> Create a new Rooibos application
70
+ run Run the application in the current directory
71
+
72
+ Options:
73
+ --version, -v Show version
74
+ --help, -h Show this help
75
+ USAGE
76
+ end
77
+ end
78
+ end
@@ -11,31 +11,36 @@ module Rooibos
11
11
  All = Data.define(:envelope, :commands, :nested) do
12
12
  include Custom
13
13
 
14
- def self.new(tag, *args)
15
- # DWIM: detect nested vs splatted based on call-site arity
16
- if args.size == 1 && args.first.is_a?(Array)
17
- commands = args.first
18
- nested = true
19
- else
20
- commands = args
21
- nested = false
22
- end
14
+ class << self
15
+ undef_method :new
16
+
17
+ def new(tag, *args)
18
+ # DWIM: detect nested vs splatted based on call-site arity
19
+ if args.size == 1 && args.first.is_a?(Array)
20
+ commands = args.first
21
+ nested = true
22
+ else
23
+ commands = args
24
+ nested = false
25
+ end
23
26
 
24
- if RatatuiRuby::Debug.enabled?
25
- commands.each do |cmd|
26
- unless Ractor.shareable?(cmd)
27
- raise Rooibos::Error::Invariant,
28
- "Command is not Ractor-shareable: #{cmd.inspect}\n" \
29
- "Use Ractor.make_shareable or a Data.define command."
27
+ if RatatuiRuby::Debug.enabled?
28
+ commands.each do |cmd|
29
+ unless Ractor.shareable?(cmd)
30
+ raise Rooibos::Error::Invariant,
31
+ "Command is not Ractor-shareable: #{cmd.inspect}\n" \
32
+ "Use Ractor.make_shareable or a Data.define command."
33
+ end
30
34
  end
31
35
  end
32
- end
33
36
 
34
- instance = allocate
35
- instance.__send__(:initialize, envelope: tag, commands: commands.freeze, nested:)
36
- instance
37
+ instance = allocate
38
+ instance.__send__(:initialize, envelope: tag, commands: commands.freeze, nested:)
39
+ instance
40
+ end
37
41
  end
38
42
 
43
+ # Executes the command, running all children in parallel.
39
44
  def call(out, token)
40
45
  # Early return for empty commands - prevents hang from zip_futures([])
41
46
  if commands.empty?
@@ -58,7 +63,7 @@ module Rooibos
58
63
  all_done = Concurrent::Promises.zip_futures(*futures)
59
64
  Concurrent::Promises.any_event(all_done, token.origin).wait
60
65
 
61
- return out.put(Command.cancel(self)) if token.canceled?
66
+ return out.put(Message::Canceled.new(command: self)) if token.canceled?
62
67
 
63
68
  shareable_results = Ractor.make_shareable(all_done.value!)
64
69
  response = Message::All.new(envelope:, results: shareable_results, nested:)
@@ -15,7 +15,7 @@ module Rooibos
15
15
  #
16
16
  # This command runs children in parallel. Each child sends its own messages
17
17
  # independently. The batch completes when all children finish or when
18
- # cancellation fires. On cancellation, emits <tt>Command.cancel(self)</tt>.
18
+ # cancellation fires. On cancellation, emits <tt>Message::Canceled</tt>.
19
19
  #
20
20
  # Use it for parallel fetches, concurrent refreshes, or any work that
21
21
  # does not need coordinated results.
@@ -37,39 +37,40 @@ module Rooibos
37
37
  class Batch < Data.define(:commands) do
38
38
  include Custom
39
39
 
40
- # Initialize
41
- def self.new(*args)
42
- # DWIM: accept (cmd1, cmd2) or ([cmd1, cmd2])
43
- commands = (args.size == 1 && args.first.is_a?(Array)) ? args.first : args
40
+ class << self
41
+ undef_method :new
44
42
 
45
- if RatatuiRuby::Debug.enabled?
46
- commands.each do |cmd|
47
- unless Ractor.shareable?(cmd)
48
- raise Rooibos::Error::Invariant,
49
- "Command is not Ractor-shareable: #{cmd.inspect}\n" \
50
- "Use Ractor.make_shareable or a Data.define command."
43
+ # Initialize
44
+ def new(*args)
45
+ # DWIM: accept (cmd1, cmd2) or ([cmd1, cmd2])
46
+ commands = (args.size == 1 && args.first.is_a?(Array)) ? args.first : args
47
+
48
+ if RatatuiRuby::Debug.enabled?
49
+ commands.each do |cmd|
50
+ unless Ractor.shareable?(cmd)
51
+ raise Rooibos::Error::Invariant,
52
+ "Command is not Ractor-shareable: #{cmd.inspect}\n" \
53
+ "Use Ractor.make_shareable or a Data.define command."
54
+ end
51
55
  end
52
56
  end
53
- end
54
57
 
55
- instance = allocate
56
- instance.__send__(:initialize, commands: commands.freeze)
57
- instance
58
+ instance = allocate
59
+ instance.__send__(:initialize, commands: commands.freeze)
60
+ instance
61
+ end
58
62
  end
59
63
 
60
64
  # Call it
61
65
  def call(out, token)
62
- futures = commands.map do |command|
63
- Concurrent::Promises.future { command.call(out, token) }
64
- end
66
+ handles = commands.map { |cmd| out.standing(cmd, token) }
67
+ out.wait(*handles, token:)
65
68
 
66
- all_done = Concurrent::Promises.zip_futures(*futures)
67
- Concurrent::Promises.any_event(all_done, token.origin).wait
68
-
69
- # Re-raise any child exception for runtime to wrap in Command::Error
70
- futures.each { |f| raise f.reason if f.rejected? }
71
-
72
- out.put(Command.cancel(self)) if token.canceled?
69
+ if token.canceled?
70
+ out.put(Message::Canceled.new(command: self))
71
+ else
72
+ out.put(Message::Batch.new(command: self))
73
+ end
73
74
  end
74
75
  end
75
76
  end