brut 0.0.1 → 0.0.2

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 (143) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +6 -0
  3. data/Gemfile.lock +66 -1
  4. data/README.md +36 -0
  5. data/Rakefile +22 -0
  6. data/brut.gemspec +7 -0
  7. data/doc-src/architecture.md +102 -0
  8. data/doc-src/assets.md +98 -0
  9. data/doc-src/forms.md +214 -0
  10. data/doc-src/handlers.md +83 -0
  11. data/doc-src/javascript.md +265 -0
  12. data/doc-src/keyword-injection.md +183 -0
  13. data/doc-src/pages.md +210 -0
  14. data/doc-src/route-hooks.md +59 -0
  15. data/docs-todo.md +32 -0
  16. data/lib/brut/back_end/seed_data.rb +5 -1
  17. data/lib/brut/back_end/validator.rb +1 -1
  18. data/lib/brut/back_end/validators/form_validator.rb +31 -6
  19. data/lib/brut/cli/app.rb +100 -4
  20. data/lib/brut/cli/app_runner.rb +38 -5
  21. data/lib/brut/cli/apps/build_assets.rb +4 -6
  22. data/lib/brut/cli/apps/db.rb +2 -7
  23. data/lib/brut/cli/apps/scaffold.rb +413 -7
  24. data/lib/brut/cli/apps/test.rb +14 -1
  25. data/lib/brut/cli/command.rb +141 -6
  26. data/lib/brut/cli/error.rb +30 -3
  27. data/lib/brut/cli/execution_results.rb +44 -6
  28. data/lib/brut/cli/executor.rb +21 -1
  29. data/lib/brut/cli/options.rb +29 -2
  30. data/lib/brut/cli/output.rb +47 -0
  31. data/lib/brut/cli.rb +26 -0
  32. data/lib/brut/factory_bot.rb +2 -0
  33. data/lib/brut/framework/app.rb +38 -5
  34. data/lib/brut/framework/config.rb +97 -54
  35. data/lib/brut/framework/container.rb +97 -33
  36. data/lib/brut/framework/errors/abstract_method.rb +3 -7
  37. data/lib/brut/framework/errors/missing_parameter.rb +12 -0
  38. data/lib/brut/framework/errors/no_class_for_path.rb +25 -0
  39. data/lib/brut/framework/errors/not_found.rb +12 -2
  40. data/lib/brut/framework/errors/not_implemented.rb +14 -0
  41. data/lib/brut/framework/errors.rb +18 -0
  42. data/lib/brut/framework/fussy_type_enforcement.rb +17 -12
  43. data/lib/brut/framework/mcp.rb +106 -49
  44. data/lib/brut/framework/project_environment.rb +9 -0
  45. data/lib/brut/framework.rb +1 -0
  46. data/lib/brut/front_end/asset_metadata.rb +7 -1
  47. data/lib/brut/front_end/component.rb +129 -38
  48. data/lib/brut/front_end/components/constraint_violations.rb +57 -0
  49. data/lib/brut/front_end/components/form_tag.rb +23 -32
  50. data/lib/brut/front_end/components/i18n_translations.rb +34 -1
  51. data/lib/brut/front_end/components/input.rb +3 -0
  52. data/lib/brut/front_end/components/inputs/csrf_token.rb +2 -0
  53. data/lib/brut/front_end/components/inputs/radio_button.rb +41 -0
  54. data/lib/brut/front_end/components/inputs/select.rb +26 -2
  55. data/lib/brut/front_end/components/inputs/text_field.rb +27 -5
  56. data/lib/brut/front_end/components/inputs/textarea.rb +24 -4
  57. data/lib/brut/front_end/components/locale_detection.rb +8 -1
  58. data/lib/brut/front_end/components/page_identifier.rb +2 -0
  59. data/lib/brut/front_end/components/time.rb +95 -0
  60. data/lib/brut/front_end/components/traceparent.rb +22 -0
  61. data/lib/brut/front_end/download.rb +11 -0
  62. data/lib/brut/front_end/flash.rb +32 -0
  63. data/lib/brut/front_end/form.rb +109 -106
  64. data/lib/brut/front_end/forms/constraint_violation.rb +16 -11
  65. data/lib/brut/front_end/forms/input.rb +30 -42
  66. data/lib/brut/front_end/forms/input_declarations.rb +90 -0
  67. data/lib/brut/front_end/forms/input_definition.rb +45 -30
  68. data/lib/brut/front_end/forms/radio_button_group_input.rb +46 -0
  69. data/lib/brut/front_end/forms/radio_button_group_input_definition.rb +29 -0
  70. data/lib/brut/front_end/forms/select_input.rb +47 -0
  71. data/lib/brut/front_end/forms/select_input_definition.rb +27 -0
  72. data/lib/brut/front_end/forms/validity_state.rb +23 -9
  73. data/lib/brut/front_end/handler.rb +27 -8
  74. data/lib/brut/front_end/handlers/csp_reporting_handler.rb +4 -2
  75. data/lib/brut/front_end/handlers/instrumentation_handler.rb +99 -0
  76. data/lib/brut/front_end/handlers/locale_detection_handler.rb +9 -4
  77. data/lib/brut/front_end/handlers/missing_handler.rb +9 -0
  78. data/lib/brut/front_end/handling_results.rb +14 -4
  79. data/lib/brut/front_end/http_method.rb +13 -1
  80. data/lib/brut/front_end/http_status.rb +10 -0
  81. data/lib/brut/front_end/layouts/_internal.html.erb +68 -0
  82. data/lib/brut/front_end/middleware.rb +5 -0
  83. data/lib/brut/front_end/middlewares/annotate_brut_owned_paths.rb +15 -0
  84. data/lib/brut/front_end/middlewares/favicon.rb +16 -0
  85. data/lib/brut/front_end/middlewares/open_telemetry_span.rb +12 -0
  86. data/lib/brut/front_end/middlewares/reload_app.rb +27 -9
  87. data/lib/brut/front_end/page.rb +50 -11
  88. data/lib/brut/front_end/pages/_missing_page.html.erb +17 -0
  89. data/lib/brut/front_end/pages/missing_page.rb +36 -0
  90. data/lib/brut/front_end/request_context.rb +117 -8
  91. data/lib/brut/front_end/route_hook.rb +43 -1
  92. data/lib/brut/front_end/route_hooks/age_flash.rb +3 -2
  93. data/lib/brut/front_end/route_hooks/csp_no_inline_scripts.rb +8 -0
  94. data/lib/brut/front_end/route_hooks/csp_no_inline_styles_or_scripts.rb +18 -10
  95. data/lib/brut/front_end/route_hooks/locale_detection.rb +11 -5
  96. data/lib/brut/front_end/route_hooks/setup_request_context.rb +7 -4
  97. data/lib/brut/front_end/routing.rb +138 -31
  98. data/lib/brut/front_end/session.rb +86 -7
  99. data/lib/brut/front_end/template.rb +17 -2
  100. data/lib/brut/front_end/templates/block_filter.rb +4 -3
  101. data/lib/brut/front_end/templates/erb_parser.rb +1 -1
  102. data/lib/brut/front_end/templates/escapable_filter.rb +2 -0
  103. data/lib/brut/front_end/templates/html_safe_string.rb +30 -2
  104. data/lib/brut/front_end/templates/locator.rb +60 -0
  105. data/lib/brut/i18n/base_methods.rb +4 -0
  106. data/lib/brut/i18n.rb +1 -0
  107. data/lib/brut/instrumentation/logger_span_exporter.rb +75 -0
  108. data/lib/brut/instrumentation/open_telemetry.rb +107 -0
  109. data/lib/brut/instrumentation.rb +4 -6
  110. data/lib/brut/junk_drawer.rb +54 -4
  111. data/lib/brut/sinatra_helpers.rb +42 -38
  112. data/lib/brut/spec_support/clock_support.rb +6 -0
  113. data/lib/brut/spec_support/component_support.rb +53 -26
  114. data/lib/brut/spec_support/e2e_test_server.rb +82 -0
  115. data/lib/brut/spec_support/enhanced_node.rb +45 -0
  116. data/lib/brut/spec_support/general_support.rb +14 -3
  117. data/lib/brut/spec_support/handler_support.rb +2 -0
  118. data/lib/brut/spec_support/matcher.rb +6 -3
  119. data/lib/brut/spec_support/matchers/be_page_for.rb +3 -2
  120. data/lib/brut/spec_support/matchers/have_constraint_violation.rb +22 -9
  121. data/lib/brut/spec_support/matchers/have_html_attribute.rb +9 -2
  122. data/lib/brut/spec_support/matchers/have_i18n_string.rb +24 -0
  123. data/lib/brut/spec_support/matchers/have_link_to.rb +14 -0
  124. data/lib/brut/spec_support/matchers/have_redirected_to.rb +30 -0
  125. data/lib/brut/spec_support/matchers/have_rendered.rb +4 -11
  126. data/lib/brut/spec_support/matchers/have_returned_http_status.rb +16 -8
  127. data/lib/brut/spec_support/rspec_setup.rb +182 -0
  128. data/lib/brut/spec_support.rb +8 -3
  129. data/lib/brut/version.rb +2 -1
  130. data/lib/brut.rb +28 -5
  131. data/lib/sequel/extensions/brut_instrumentation.rb +5 -27
  132. data/lib/sequel/extensions/brut_migrations.rb +18 -8
  133. data/lib/sequel/plugins/created_at.rb +2 -0
  134. data/lib/sequel/plugins/external_id.rb +39 -1
  135. data/lib/sequel/plugins/find_bang.rb +4 -1
  136. metadata +140 -13
  137. data/lib/brut/back_end/action.rb +0 -3
  138. data/lib/brut/back_end/result.rb +0 -46
  139. data/lib/brut/front_end/components/timestamp.rb +0 -33
  140. data/lib/brut/instrumentation/basic.rb +0 -66
  141. data/lib/brut/instrumentation/event.rb +0 -19
  142. data/lib/brut/instrumentation/http_event.rb +0 -5
  143. data/lib/brut/instrumentation/subscriber.rb +0 -41
@@ -1,8 +1,14 @@
1
1
  require "optparse"
2
+ # Base class for subcommands of a {Brut::CLI::App}. You must implement {#execute} to perform whatever action this command must perform.
2
3
  class Brut::CLI::Command
3
4
  include Brut::CLI::ExecutionResults
4
5
  include Brut::I18n::ForCLI
6
+ include Brut::Framework::Errors
5
7
 
8
+ # Call this to set the one-line description of this command
9
+ #
10
+ # @param new_description [String] When present, sets the description of this command. When omitted, returns the current description.
11
+ # @return [String] the current description (if called with no parameters)
6
12
  def self.description(new_description=nil)
7
13
  if new_description.nil?
8
14
  return @description.to_s
@@ -10,6 +16,12 @@ class Brut::CLI::Command
10
16
  @description = new_description
11
17
  end
12
18
  end
19
+
20
+
21
+ # Call this to set a an additional detailed description of this command. Currently, this should not be formatted and will be shown all on one line. This is shown after the `description`, so this text can follow from that, without having to restate it.
22
+ #
23
+ # @param new_description [String] When present, sets the detailed description of this command. When omitted, returns the current detailed description.
24
+ # @return [String] the current detailed description (if called with no parameters)
13
25
  def self.detailed_description(new_description=nil)
14
26
  if new_description.nil?
15
27
  if @detailed_description.nil?
@@ -20,6 +32,17 @@ class Brut::CLI::Command
20
32
  @detailed_description = new_description
21
33
  end
22
34
  end
35
+
36
+ # Set a description of the command line args this command accepts. Args are any part of the command line that is not a switch or
37
+ # flag. The string you give will be used only for documentation. Typically, you would format it in one a few ways:
38
+ #
39
+ # * `args "some_arg"` indicates that exactly one arg called `some_arg` is required
40
+ # * `args "[some_arg]"` indicates that exactly one arg called `some_arg` is optional
41
+ # * `args "args..."` indicates that
42
+ # * `args "[args...]"` indicates that zero or more args called `args` are accepted
43
+ #
44
+ # @param [String] new_args documentation for this command's args. If omitted, returns the current value.
45
+ # @return [String the current value (if called with no parameters)
23
46
  def self.args(new_args=nil)
24
47
  if new_args.nil?
25
48
  return @args.to_s
@@ -27,19 +50,67 @@ class Brut::CLI::Command
27
50
  @args = new_args
28
51
  end
29
52
  end
53
+
54
+ # Call this for each environment variable this *command* responds to. These would be variables that affect only this command. For
55
+ # app-wide environment variables, see {Brut::CLI::App.env_var}.
56
+ #
57
+ # @param var_name [String] Declares that this command recognizes this environment variable.
58
+ # @param purpose [String] An explanation for how this environment variable affects the command. Used in documentation.
59
+ def self.env_var(var_name,purpose:)
60
+ env_vars[var_name] = purpose
61
+ end
62
+
63
+ # Access all configured environment variables.
64
+ # @!visibility private
65
+ def self.env_vars
66
+ @env_vars ||= {
67
+ }
68
+ end
69
+
70
+ # Returns the name of this command, for use on the command line. By default, returns the "underscorized" name of this class
71
+ # (excluding any module namespaces). For exaple, if your command's class is `MyApp::CLI::Commands::ClearFiles::DryRun`, the command
72
+ # name would be `"dry_run"`. You can override this if you want something different. It is recommended that it not include spaces
73
+ # or other characters meaningful to the shell, but if you like quotes, cool.
30
74
  def self.command_name = RichString.new(self.name.split(/::/).last).underscorized
75
+
76
+ # Checks if a given string matches this command name. This exists to allow the use of underscores or dashes as delimiters. Ruby
77
+ # likes underscores, but the shell vibe is often dashes.
78
+ #
79
+ # @param [String] string the command given on the command line
80
+ # @return [true|false] true if `string` is considered an exact match for this command's name.
31
81
  def self.name_matches?(string)
32
82
  self.command_name == string || self.command_name.to_s.gsub(/_/,"-") == string
33
83
  end
84
+
85
+ # Provides access to an `OptionParser` you can use to declare flags and switches that should be accepted only by this command.
86
+ # The way to use this is to call `.on` and provide a description for an option as you would to `OptionParser`.
87
+ # The only difference is that you should not pass a block to this. When the command line is parsed, the results will be placed
88
+ # into a {Brut::CLI::Options} instance made available to your command.
89
+ #
90
+ # @return [OptionParser]
34
91
  def self.opts
35
92
  self.option_parser
36
93
  end
94
+
95
+ # Returns the configured `OptionParser` used to parse the command portion of the command line.
96
+ # If you don't want to call {.opts}, you can create and return
97
+ # a fully-formed `OptionParser` by overriding this method. By default, it will create one with a conventional banner.
98
+ #
99
+ # @return [OptionParser]
37
100
  def self.option_parser
38
101
  @option_parser ||= OptionParser.new do |opts|
39
102
  opts.banner = "%{app} %{global_options} #{command_name} %{command_options} %{args}"
40
103
  end
41
104
  end
42
105
 
106
+ # Call this if this command requires a project environment as context for what it does. When called, this will do a few things:
107
+ #
108
+ # * Your command (not app) will recognize `--env=ENVIRONMENT` as a global option
109
+ # * Your command will document that it recognizes the `RACK_ENV` environment variable.
110
+ #
111
+ # @see Brut::CLI::App.requires_project_env
112
+ #
113
+ # @param default [String] name of the environment to use if none was specified. `nil` should be used to require the environment to be specified explicitly.
43
114
  def self.requires_project_env(default: "development")
44
115
  default_message = if default.nil?
45
116
  ""
@@ -49,11 +120,22 @@ class Brut::CLI::Command
49
120
  opts.on("--env=ENVIRONMENT","Project environment#{default_message}")
50
121
  @default_env = default
51
122
  @requires_project_env = true
123
+ self.env_var("RACK_ENV",purpose: "default project environment when --env is omitted")
52
124
  end
53
125
 
126
+ # Returns the default project env, based on the logic described in {.requires_project_env}
54
127
  def self.default_env = @default_env
128
+ # Returns true if this app requires a project env
55
129
  def self.requires_project_env? = @requires_project_env
56
130
 
131
+ # Creates the command before executing it. Generally you would not call this directly.
132
+ #
133
+ # @param [Brut::CLI::Options] command_options the command options parsed from the command line.
134
+ # @param [Brut::CLI::Options] global_options the global options parsed from the command line.
135
+ # @param [Array] args Any unparsed arguments
136
+ # @param [Brut::CLI::Output] out an IO used to send messages to the standard output
137
+ # @param [Brut::CLI::Output] err an IO used to send messages to the standard error
138
+ # @param [Brut::CLI::Executor] executor used to execute child processes instead of e.g. `system`
57
139
  def initialize(command_options:,global_options:, args:,out:,err:,executor:)
58
140
  @command_options = command_options
59
141
  @global_options = global_options
@@ -66,8 +148,20 @@ class Brut::CLI::Command
66
148
  end
67
149
  end
68
150
 
151
+ # Convienince method to call {Brut::CLI::Executor#system!} on the executor given in the constructor.
152
+ # @param (see Brut::CLI::Executor#system!)
153
+ # @return (see Brut::CLI::Executor#system!)
69
154
  def system!(*args) = @executor.system!(*args)
70
155
 
156
+ # Use this inside {#execute} to createa compound command that executes other commands that are a part of your CLI app.
157
+ # Note that each command will be given the same global and commmand options and the same arguments, so these commands must be able
158
+ # to complete as desired in that way.
159
+ #
160
+ # Note that since commands are just classes, you can certianly create them however you like and call `execute` yourself.
161
+ #
162
+ # @param [Enumerable<Class>] command_klasses one or more classes that subclass {Brut::CLI::Command} to delegate to.
163
+ # @return [Brut::CLI::ExecutionResults::Result] the first non successful result is returned and processing is stopped, otherwise returns the
164
+ # result of the last class executed.
71
165
  def delegate_to_commands(*command_klasses)
72
166
  result = nil
73
167
  command_klasses.each do |command_klass|
@@ -80,28 +174,52 @@ class Brut::CLI::Command
80
174
  result
81
175
  end
82
176
 
83
- def delegate_to_command(command_klass)
84
- command = command_klass.new(command_options: options, global_options:, args:, out:, err:, executor: @executor)
85
- as_execution_result(command.execute)
86
- end
87
-
177
+ # You must implement this to perform whatever action your command needs to perform. In order to do this, you will have access to:
178
+ #
179
+ # * {#options} - the command options passed on the command line
180
+ # * {#global_options} - the global options passed on the command line
181
+ # * {#args} - the args passed on the command line
182
+ # * {#out} - an IO you should use to print messages to the standard out.
183
+ # * {#err} - on IO you should use to print messages to the standard error.
184
+ # * {#system!} - the method you should use to spawn child processes.
185
+ #
186
+ # @return [Brut::CLI::ExecutionResults::Result] a description of what happened during processing. It is preferable to try to return
187
+ # something instead of raising an exception.
188
+ # @raise [Brurt::CLI::Error] if thrown, this will be caught and handled by {Brut::CLI::AppRunner}.
189
+ # @raise [StandardError] if thrown, this will bubble up and show your user a very sad stack trace that will make them cry. Don't.
88
190
  def execute
89
- raise Brut::Framework::Errors::AbstractMethod
191
+ abstract_method!
90
192
  end
91
193
 
194
+ # Called before any execution or bootstrapping happens but after {Brut::CLI::App#before_execute} is called. This will have access
195
+ # to everything {#execute} can access.
196
+ # @raise [Brurt::CLI::Error] if thrown, this will be caught and handled by {Brut::CLI::AppRunner} and execution will be aborted.
197
+ # @raise [StandardError] if thrown, this will bubble up and show your user a very sad stack trace that will make them cry. Don't.
92
198
  def before_execute
93
199
  end
94
200
 
201
+ # @!visibility private
95
202
  def set_env_if_needed
96
203
  if self.class.requires_project_env?
97
204
  ENV["RACK_ENV"] = options.env
98
205
  end
99
206
  end
100
207
 
208
+ # Called if there is an exception during bootstrapping. By default, it re-raises the exception, which cases the command to abort.
209
+ # The reason you may want to overrid this is that your command line app may exist to handle a bootstrapping exception. For example,
210
+ # the built-in {Brut::CLI::Apps::DB} app will catch various database connection errors and then create or migrate the database.
211
+ #
212
+ # Yes, I realize this means we are using exceptions for control flow. It's fine.
213
+ #
214
+ # @param [StandardError] ex whichever exception was caught during bootstrapping
215
+ # @return [void] ignored
216
+ # @raise [Brurt::CLI::Error] if thrown, this will be caught and handled by {Brut::CLI::AppRunner} and execution will be aborted.
217
+ # @raise [StandardError] if thrown, this will bubble up and show your user a very sad stack trace that will make them cry. Don't.
101
218
  def handle_bootstrap_exception(ex)
102
219
  raise ex
103
220
  end
104
221
 
222
+ # @!visibility private
105
223
  def bootstrap!(project_root:, configure_only:)
106
224
  require "bundler"
107
225
  Bundler.require(:default, ENV["RACK_ENV"].to_sym)
@@ -116,15 +234,32 @@ class Brut::CLI::Command
116
234
 
117
235
  private
118
236
 
237
+ # @!visibility public
238
+ # @return [Brut::CLI::Options] the command options parsed on the command line
119
239
  def options = @command_options
240
+ # @!visibility public
241
+ # @return [Brut::CLI::Options] the global options parsed on the command line
120
242
  def global_options = @global_options
243
+ # @!visibility public
244
+ # @return [Array<String>] the arguments parsed from the command line
121
245
  def args = @args
246
+ # @!visibility public
247
+ # @return [Brut::CLI::Output] IO to use for sending messages to the standard output
122
248
  def out = @out
249
+ # @!visibility public
250
+ # @return [Brut::CLI::Output] IO to use for sending messages to the standard error
123
251
  def err = @err
124
252
 
253
+ # Exists to warn users not to use `puts`
125
254
  def puts(...)
126
255
  warn("Your CLI apps should use out and err to produce terminal output, not puts", uplevel: 1)
127
256
  Kernel.puts(...)
128
257
  end
129
258
 
259
+ def delegate_to_command(command_klass)
260
+ command = command_klass.new(command_options: options, global_options:, args:, out:, err:, executor: @executor)
261
+ as_execution_result(command.execute)
262
+ end
263
+
264
+
130
265
  end
@@ -1,12 +1,39 @@
1
- # Marker that means an expected error happens and
2
- # we don't need to show the stack trace
1
+ # All errors from a Brut CLI should extend this. Any error that does will be caught and handled without showing the user a stack
2
+ # trace.
3
3
  class Brut::CLI::Error < StandardError
4
4
  end
5
+ # Raised when a child process executed by {Brut::CLI::Executor} returns a nonzero exit status.
6
+ #
7
+ # @see Brut::CLI::Executor#system!
5
8
  class Brut::CLI::SystemExecError < Brut::CLI::Error
6
- attr_reader :command,:exit_status
9
+ # @return [String|Array] command line invocation that caused the error
10
+ attr_reader :command
11
+ # @return [Integer] exit status of the command
12
+ attr_reader :exit_status
13
+ # @param [String|Array] command or args passed to {Brut::CLI::Executor#system!}.
14
+ # @param [Integer] exit_status the exit status of the command.
7
15
  def initialize(command,exit_status)
8
16
  super("#{command} failed - exited #{exit_status}")
9
17
  @command = command
10
18
  @exit_status = exit_status
11
19
  end
12
20
  end
21
+
22
+ # Raised when an `OptionParser::ParseError` is caught to provide a more useful
23
+ # error message given the global/command optiona dichotomy.
24
+ class Brut::CLI::InvalidOption < Brut::CLI::Error
25
+ def initialize(option_parser_parse_error, context:)
26
+ args = option_parser_parse_error.args
27
+ count_message = if option_parser_parse_error.args.length == 1
28
+ "isn't a valid option"
29
+ else
30
+ "aren't valid options"
31
+ end
32
+ type_message = if context.kind_of?(Class)
33
+ "for the '#{context.command_name}' command"
34
+ else
35
+ "for the app globally"
36
+ end
37
+ super("#{args.join(", ")} #{count_message} #{type_message}")
38
+ end
39
+ end
@@ -1,30 +1,43 @@
1
1
  module Brut
2
2
  module CLI
3
+ # Included in commands to allow convienient ways to return structured information about what happened during command
4
+ # execution.
3
5
  module ExecutionResults
6
+ # Base class for all results.
4
7
  class Result
8
+ # @return [Strting] any message provided in the results. Generally `nil` for successful results.
5
9
  attr_reader :message
10
+
11
+ # Create a new result
12
+ #
13
+ # @param exit_status [Integer] exit status desired for this command. 0 is treated as success by any command that called your
14
+ # command. All other values have command-defined meanings.
15
+ # @param message [String|nil] a message to explain the results, if relevant.
6
16
  def initialize(exit_status:,message:nil)
7
17
  @exit_status = exit_status
8
18
  @message = message
9
19
  end
10
20
 
11
- # Returns true if execution internal to the command should stop
21
+ # @return [true|false] true if execution internal to the command should stop
12
22
  def stop? = @exit_status != 0
13
- # Returns true if the execution of the command succeeded or didn't error
23
+ # @return [true|false] true if the execution of the command succeeded or didn't error
14
24
  def ok? = @exit_status == 0
15
- # Returns the exit status to use for the CLI
25
+ # @return [Integer] the exit status
16
26
  def to_i = @exit_status
27
+ # @return [true|false] true if the result means that the user should be show CLI usage in addition to any messaging.
17
28
  def show_usage? = false
18
29
  end
19
30
 
20
- # Stop execution, even though nothing is wrong
31
+ # @!visibility private
21
32
  class Stop < Result
22
33
  def initialize
23
34
  super(exit_status: 0)
24
35
  end
36
+ # @return [true] true
25
37
  def stop? = true
26
38
  end
27
39
 
40
+ # @!visibility private
28
41
  class ShowCLIUsage < Stop
29
42
  attr_reader :command_klass
30
43
  def initialize(command_klass:)
@@ -34,14 +47,14 @@ module Brut
34
47
  def show_usage? = true
35
48
  end
36
49
 
37
- # Continue execution
50
+ # @!visibility private
38
51
  class Continue < Result
39
52
  def initialize
40
53
  super(exit_status: 0)
41
54
  end
42
55
  end
43
56
 
44
- # Abort execution immediately
57
+ # @!visibility private
45
58
  class Abort < Result
46
59
  def initialize(exit_status:1,message:nil)
47
60
  if exit_status == 0
@@ -50,6 +63,7 @@ module Brut
50
63
  super(exit_status:,message:)
51
64
  end
52
65
  end
66
+ # @!visibility private
53
67
  class CLIUsageError < Abort
54
68
  def initialize(message:)
55
69
  super(message:,exit_status:65)
@@ -57,12 +71,36 @@ module Brut
57
71
  def show_usage? = true
58
72
  end
59
73
 
74
+ # Return this from {Brut::CLI::Command#execute} to stop execution, without signaling an error
60
75
  def stop_execution = Stop.new
76
+ # Return this from {Brut::CLI::Command#execute} to indicate any other execution may continue. This is mostly useful when one
77
+ # command calls another e.g. with {Brut::CLI::Command#delegate_to_commands}.
61
78
  def continue_execution = Continue.new
79
+ # Return this from {Brut::CLI::Command#execute} to stop execution and signal an error
80
+ #
81
+ # @param [String] message the error message
82
+ # @param [Integer] exit_status the exit status (should ideally not be 0)
62
83
  def abort_execution(message,exit_status:1) = Abort.new(message:,exit_status:)
84
+ # Return this from {Brut::CLI::Command#execute} to stop execution because the user messed up the CLI invocation.
85
+ #
86
+ # @param [String] message the error message
63
87
  def cli_usage_error(message) = CLIUsageError.new(message:)
88
+ # Return this from {Brut::CLI::Command#execute} to stop execution, and show the user the CLI help, optionally for a specific
89
+ # command.
90
+ #
91
+ # @param [Class] command_klass if given, this it he class whose help will be shown.
64
92
  def show_cli_usage(command_klass=nil) = ShowCLIUsage.new(command_klass:)
65
93
 
94
+ # Coerce a value to the appropriate {Brut::CLI::ExecutionResults::Result}. Currently works as follows:
95
+ #
96
+ # 1. If `exit_status_or_execution_result` is nil or true, returns a successful result
97
+ # 2. If `exit_status_or_execution_result` is an integer, returns a result with no message and that value as the exit status
98
+ # 3. If `exit_status_or_execution_result` is false, returns {#abort_execution}.
99
+ # 4. If `exit_status_or_execution_result` is a {Brut::CLI::ExecutionResults::Result}, returns that
100
+ # 5. Otherwise, raises `ArgumentError`
101
+ #
102
+ # @param [nil|true|false|Brut::CLI::ExecutionResults::Result|Integer] exit_status_or_execution_result the value to coerce
103
+ # @return [Brut::CLI::ExecutionResults::Result] appropriate for the parameter
66
104
  def as_execution_result(exit_status_or_execution_result)
67
105
  if exit_status_or_execution_result.kind_of?(Numeric) || exit_status_or_execution_result.nil?
68
106
  Result.new(exit_status: exit_status_or_execution_result.to_i)
@@ -1,9 +1,29 @@
1
1
  require "open3"
2
+ # Abstracts the invocation of child processes, but includes useful output and exception handling that you'd need to be transparent to
3
+ # the user. In particular:
4
+ #
5
+ # * Outputs the command being executed to the standard error (via {Brut::CLI::Output}).
6
+ # * Outputs the command's standard error and standard output in real time. So, this should be usable for spawned commands that
7
+ # produce real time output like test runners.
8
+ # * Assumes spawned commands should succeed, raising an error if they do not.
2
9
  class Brut::CLI::Executor
10
+ # Create the executor
11
+ #
12
+ # @param [Brut::CLI::Output] out an IO used to send messages to the standard output
13
+ # @param [Brut::CLI::Output] err an IO used to send messages to the standard error
3
14
  def initialize(out:,err:)
4
15
  @out = out
5
16
  @err = err
6
17
  end
18
+
19
+ # Execute a command, logging it to the standard output and outputing the commands output and error to the standard output and error,
20
+ # respecitively
21
+ #
22
+ # @see https://docs.ruby-lang.org/en/3.3/Open3.html#method-c-popen3
23
+ #
24
+ # @param [String|Array] args Whatever you would give to `Kernel#system` or `Open3.popen3`.
25
+ # @raise Brut::CLI::Error::SystemExecError if the spawed command exits nonzer
26
+ # @return [true]
7
27
  def system!(*args)
8
28
  @out.puts "Executing #{args}"
9
29
  wait_thread = Open3.popen3(*args) do |_stdin,stdout,stderr,wait_thread|
@@ -30,7 +50,7 @@ class Brut::CLI::Executor
30
50
  if wait_thread.value.success?
31
51
  @out.puts "#{args} succeeded"
32
52
  else
33
- raise Brut::CLI::SystemExecError.new(*args,wait_thread.value.exitstatus)
53
+ raise Brut::CLI::SystemExecError.new(args,wait_thread.value.exitstatus)
34
54
  end
35
55
  true
36
56
  end
@@ -1,19 +1,46 @@
1
- # Convienience module to put into Hash to allow options
2
- # parsed to be a bit more accessible
1
+ # Wraps parsed command line options to provide a method-like interface instead of simple `Hash` acceess. Also allows specifying default values when an option wasn't given on the command line, as well as boolean coercion for switches. Allows accessing options via snake_case in code, even if specified via kebab-case on the command line.
3
2
  class Brut::CLI::Options
4
3
  def initialize(parsed_options)
5
4
  @parsed_options = parsed_options
6
5
  @defaults = {}
7
6
  end
8
7
 
8
+ # Returns the parsed options as a `Hash`
9
+ # @return [Hash]
9
10
  def to_h = @parsed_options
10
11
 
12
+ # Access an options value directly.
13
+ # @param [String] key the key to use. This must be the exact name you used when calling `opts.on` or when creating the `OptionParser` for your app or command. Generally, use the {#method_missing}-provided version instead of this.
11
14
  def [](key) = @parsed_options[key]
15
+ # Check if `key` was provided on the command line.
16
+ # @param [String] key the key to use. This must be the exact name you used when calling `opts.on` or when creating the `OptionParser` for your app or command.
12
17
  def key?(key) = @parsed_options.key?(key)
18
+
19
+ # Set a default value for an option when {#method_missing} is used to access it and that flag or switch was not used on the command
20
+ # line.
21
+ # @param [Symbol] sym the symbol of the option, either in kebab-case or snake_case.
22
+ # @param [Object] default_value the value to return if that option was not used on the command line
13
23
  def set_default(sym,default_value)
14
24
  @defaults[sym] = default_value
15
25
  end
16
26
 
27
+ # Dynamically creates methods for each command-line option. Note that this doesn't know what options could've been provided, so it
28
+ # will respond to any method that either takes no arguments or where the only argument is `default:`.
29
+ #
30
+ # @param [Symbol] sym kebab or snake case value of the option. A question mark should be used if the option is to be coerced to a booealn.
31
+ # @param [Array|Hash] args if the first arg is a Hash with the key `default:`, this value will be used if `sym` has no value
32
+ #
33
+ # @return [true|false|nil|Object] the value of the option used on the command line, or the default used in `default:`, or the
34
+ # preconfigured default, or `nil`. If `sym` ended in a question mark, true or false will be returned (never nil).
35
+ #
36
+ # @example
37
+ #
38
+ # # Suppose the user provided `--log-level=debug --dry-run` on the command line
39
+ # options.log_level # => "debug"
40
+ # options.verbose # => nil
41
+ # options.verbose(default: "yes") # => "yes"
42
+ # options.dry_run? # => true
43
+ # options.slow? # => false
17
44
  def method_missing(sym,*args,&block)
18
45
  boolean = false
19
46
  if sym.to_s =~ /\?$/
@@ -1,14 +1,41 @@
1
+ # An `IO`-like class that provides user-helpful output, to be used in place of `puts`. This is not strictly an `IO` and is intended to provide only a few options for sending output to a human.
2
+ #
3
+ # Command line apps that a human runs are often executed in the context of other apps or themselves spawn child processes. Thus, it
4
+ # can be hard to know what output is coming from where. This class addresses that by prefixing every line with the name of the
5
+ # command line app:
6
+ #
7
+ # ```
8
+ # > bin/my_app doit
9
+ # [ bin/my_app ] About to do sometohing
10
+ # Cannot connect to house
11
+ # [ bin/my_app ] Problem connecting
12
+ # ```
13
+ #
14
+ # The prefix allows the human to know what output was generated by the app they ran and what not. This can be extremely helpful for
15
+ # debugging or just understanding what is going on.
1
16
  class Brut::CLI::Output
17
+ # Create a wrapper for the given IO that will use the given prefix
18
+ #
19
+ # @param [IO] io an IO where output should be sent, for example `$stdout`.
20
+ # @param [String] prefix a prefix to be placed in front of all messages (unless a `_no_prefix` version is called)
2
21
  def initialize(io:, prefix:)
3
22
  @io = io
4
23
  @prefix = prefix
5
24
  @sync_status = @io.sync
6
25
  end
7
26
 
27
+ # Calls `puts` without the prefix. This is useful if the output is going to be obvious to the human user (e.g. CLI help), or if
28
+ # it's intended to be piped into another app.
29
+ #
30
+ # @see https://ruby-doc.org/3.3.6/Kernel.html#method-i-puts
8
31
  def puts_no_prefix(*objects)
9
32
  @io.puts(*objects)
10
33
  end
11
34
 
35
+ # Calls `puts`, adding a prefix to each of the objects in `*objects`. This is useful for sending messages that a human may want to
36
+ # read.
37
+ #
38
+ # @see https://ruby-doc.org/3.3.6/Kernel.html#method-i-puts
12
39
  def puts(*objects)
13
40
  if objects.empty?
14
41
  objects << ""
@@ -19,10 +46,30 @@ class Brut::CLI::Output
19
46
  nil
20
47
  end
21
48
 
49
+ # Calls `print` without any prefix. In theory, this is the escape hatch to sending arbitrary data to the underlying `IO` without
50
+ # expose said `IO`
51
+ #
52
+ # @see https://ruby-doc.org/3.3.6/Kernel.html#method-i-print
22
53
  def print(*objects)
23
54
  @io.print(*objects)
24
55
  end
25
56
 
57
+ # Prints a string via `printf`, using the prefix. This is useful for communciating to a human, but you need more power for
58
+ # formatting than is afforded by {#puts}.
59
+ #
60
+ # @see https://ruby-doc.org/3.3.6/Kernel.html#method-i-printf
61
+ def printf(format_string,*objects)
62
+ @io.printf(@prefix + format_string,*objects)
63
+ end
64
+
65
+ # Prints a string via `printf`, without the prefix.
66
+ #
67
+ # @see https://ruby-doc.org/3.3.6/Kernel.html#method-i-printf
68
+ def printf_no_prefix(format_string,*objects)
69
+ @io.printf(format_string,*objects)
70
+ end
71
+
72
+ # Flush the underlying `IO`.
26
73
  def flush
27
74
  @io.flush
28
75
  self
data/lib/brut/cli.rb CHANGED
@@ -1,6 +1,30 @@
1
1
  module Brut
2
+ # Brut provides a basic CLI framework for building CLIs that have access to your Brut app's innerworkings.
3
+ # This is an alternative to Rake tasks which suffer from poor usability and testability.
4
+ #
5
+ # To create a CLI, you will subclass {Brut::CLI::App}. That class will define your UI as well as any subcommands
6
+ # that your CLI will respond to. See {Brut::CLI::app}.
2
7
  module CLI
3
8
 
9
+ # Execute your CLI based on its command line invocation. You would call this method inside the executable file placed in `bin/`
10
+ # in your project. For example, if you have `YourApp::CLI::CleanOldFiles` and you wish to execute it via `bin/clean-files`, you'd
11
+ # create `bin/clean-files` like so:
12
+ #
13
+ # ```
14
+ # #!/usr/bin/env ruby
15
+ #
16
+ # require "bundler"
17
+ # Bundler.require
18
+ # require "pathname"
19
+ # require "brut/cli/apps/db"
20
+ #
21
+ # exit Brut::CLI.app(YourApp::CLI::CleanOldFiles,
22
+ # project_root: Pathname($0).dirname / "..")
23
+ # ```
24
+ #
25
+ # @param app_klass [Class] your CLI app's class.
26
+ # @param project_root [Pathname] the path to the root of your project. This is needed before the Brut framework is initialized so
27
+ # it must be specified explicitly.
4
28
  def self.app(app_klass, project_root:)
5
29
  Brut::CLI::AppRunner.new(app_klass:,project_root:).run!
6
30
  end
@@ -8,11 +32,13 @@ module Brut
8
32
  autoload(:Command, "brut/cli/command")
9
33
  autoload(:Error, "brut/cli/error")
10
34
  autoload(:SystemExecError, "brut/cli/error")
35
+ autoload(:InvalidOption, "brut/cli/error")
11
36
  autoload(:ExecutionResults, "brut/cli/execution_results")
12
37
  autoload(:Options, "brut/cli/options")
13
38
  autoload(:Output, "brut/cli/output")
14
39
  autoload(:Executor, "brut/cli/executor")
15
40
  autoload(:AppRunner, "brut/cli/app_runner")
41
+ # Holds Brut-provided CLI apps that are set up in your project.
16
42
  module Apps
17
43
  autoload(:DB,"brut/cli/apps/db")
18
44
  autoload(:DB,"brut/cli/apps/test")
@@ -5,7 +5,9 @@ require "active_support"
5
5
  require "factory_bot"
6
6
  require "faker"
7
7
 
8
+ # Encompasses a Brut app's FactoryBot configuration. This allows it to be used outside of tests.
8
9
  class Brut::FactoryBot
10
+ # Configures FactoryBot and finds all definitions. After this, calls like `FactoryBot.create(...)` should work.
9
11
  def setup!
10
12
  Faker::Config.locale = :en
11
13
  FactoryBot.definition_file_paths = [