brut 0.0.1 → 0.0.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.gitignore +6 -0
- data/Gemfile.lock +66 -1
- data/README.md +36 -0
- data/Rakefile +22 -0
- data/brut.gemspec +7 -0
- data/doc-src/architecture.md +102 -0
- data/doc-src/assets.md +98 -0
- data/doc-src/forms.md +214 -0
- data/doc-src/handlers.md +83 -0
- data/doc-src/javascript.md +265 -0
- data/doc-src/keyword-injection.md +183 -0
- data/doc-src/pages.md +210 -0
- data/doc-src/route-hooks.md +59 -0
- data/docs-todo.md +32 -0
- data/lib/brut/back_end/seed_data.rb +5 -1
- data/lib/brut/back_end/validator.rb +1 -1
- data/lib/brut/back_end/validators/form_validator.rb +31 -6
- data/lib/brut/cli/app.rb +100 -4
- data/lib/brut/cli/app_runner.rb +38 -5
- data/lib/brut/cli/apps/build_assets.rb +4 -6
- data/lib/brut/cli/apps/db.rb +2 -7
- data/lib/brut/cli/apps/scaffold.rb +413 -7
- data/lib/brut/cli/apps/test.rb +14 -1
- data/lib/brut/cli/command.rb +141 -6
- data/lib/brut/cli/error.rb +30 -3
- data/lib/brut/cli/execution_results.rb +44 -6
- data/lib/brut/cli/executor.rb +21 -1
- data/lib/brut/cli/options.rb +29 -2
- data/lib/brut/cli/output.rb +47 -0
- data/lib/brut/cli.rb +26 -0
- data/lib/brut/factory_bot.rb +2 -0
- data/lib/brut/framework/app.rb +38 -5
- data/lib/brut/framework/config.rb +97 -54
- data/lib/brut/framework/container.rb +97 -33
- data/lib/brut/framework/errors/abstract_method.rb +3 -7
- data/lib/brut/framework/errors/missing_parameter.rb +12 -0
- data/lib/brut/framework/errors/no_class_for_path.rb +25 -0
- data/lib/brut/framework/errors/not_found.rb +12 -2
- data/lib/brut/framework/errors/not_implemented.rb +14 -0
- data/lib/brut/framework/errors.rb +18 -0
- data/lib/brut/framework/fussy_type_enforcement.rb +17 -12
- data/lib/brut/framework/mcp.rb +106 -49
- data/lib/brut/framework/project_environment.rb +9 -0
- data/lib/brut/framework.rb +1 -0
- data/lib/brut/front_end/asset_metadata.rb +7 -1
- data/lib/brut/front_end/component.rb +129 -38
- data/lib/brut/front_end/components/constraint_violations.rb +57 -0
- data/lib/brut/front_end/components/form_tag.rb +23 -32
- data/lib/brut/front_end/components/i18n_translations.rb +34 -1
- data/lib/brut/front_end/components/input.rb +3 -0
- data/lib/brut/front_end/components/inputs/csrf_token.rb +2 -0
- data/lib/brut/front_end/components/inputs/radio_button.rb +41 -0
- data/lib/brut/front_end/components/inputs/select.rb +26 -2
- data/lib/brut/front_end/components/inputs/text_field.rb +27 -5
- data/lib/brut/front_end/components/inputs/textarea.rb +24 -4
- data/lib/brut/front_end/components/locale_detection.rb +8 -1
- data/lib/brut/front_end/components/page_identifier.rb +2 -0
- data/lib/brut/front_end/components/time.rb +95 -0
- data/lib/brut/front_end/components/traceparent.rb +22 -0
- data/lib/brut/front_end/download.rb +11 -0
- data/lib/brut/front_end/flash.rb +32 -0
- data/lib/brut/front_end/form.rb +109 -106
- data/lib/brut/front_end/forms/constraint_violation.rb +16 -11
- data/lib/brut/front_end/forms/input.rb +30 -42
- data/lib/brut/front_end/forms/input_declarations.rb +90 -0
- data/lib/brut/front_end/forms/input_definition.rb +45 -30
- data/lib/brut/front_end/forms/radio_button_group_input.rb +46 -0
- data/lib/brut/front_end/forms/radio_button_group_input_definition.rb +29 -0
- data/lib/brut/front_end/forms/select_input.rb +47 -0
- data/lib/brut/front_end/forms/select_input_definition.rb +27 -0
- data/lib/brut/front_end/forms/validity_state.rb +23 -9
- data/lib/brut/front_end/handler.rb +27 -8
- data/lib/brut/front_end/handlers/csp_reporting_handler.rb +4 -2
- data/lib/brut/front_end/handlers/instrumentation_handler.rb +99 -0
- data/lib/brut/front_end/handlers/locale_detection_handler.rb +9 -4
- data/lib/brut/front_end/handlers/missing_handler.rb +9 -0
- data/lib/brut/front_end/handling_results.rb +14 -4
- data/lib/brut/front_end/http_method.rb +13 -1
- data/lib/brut/front_end/http_status.rb +10 -0
- data/lib/brut/front_end/layouts/_internal.html.erb +68 -0
- data/lib/brut/front_end/middleware.rb +5 -0
- data/lib/brut/front_end/middlewares/annotate_brut_owned_paths.rb +15 -0
- data/lib/brut/front_end/middlewares/favicon.rb +16 -0
- data/lib/brut/front_end/middlewares/open_telemetry_span.rb +12 -0
- data/lib/brut/front_end/middlewares/reload_app.rb +27 -9
- data/lib/brut/front_end/page.rb +50 -11
- data/lib/brut/front_end/pages/_missing_page.html.erb +17 -0
- data/lib/brut/front_end/pages/missing_page.rb +36 -0
- data/lib/brut/front_end/request_context.rb +117 -8
- data/lib/brut/front_end/route_hook.rb +43 -1
- data/lib/brut/front_end/route_hooks/age_flash.rb +3 -2
- data/lib/brut/front_end/route_hooks/csp_no_inline_scripts.rb +8 -0
- data/lib/brut/front_end/route_hooks/csp_no_inline_styles_or_scripts.rb +18 -10
- data/lib/brut/front_end/route_hooks/locale_detection.rb +11 -5
- data/lib/brut/front_end/route_hooks/setup_request_context.rb +7 -4
- data/lib/brut/front_end/routing.rb +138 -31
- data/lib/brut/front_end/session.rb +86 -7
- data/lib/brut/front_end/template.rb +17 -2
- data/lib/brut/front_end/templates/block_filter.rb +4 -3
- data/lib/brut/front_end/templates/erb_parser.rb +1 -1
- data/lib/brut/front_end/templates/escapable_filter.rb +2 -0
- data/lib/brut/front_end/templates/html_safe_string.rb +30 -2
- data/lib/brut/front_end/templates/locator.rb +60 -0
- data/lib/brut/i18n/base_methods.rb +4 -0
- data/lib/brut/i18n.rb +1 -0
- data/lib/brut/instrumentation/logger_span_exporter.rb +75 -0
- data/lib/brut/instrumentation/open_telemetry.rb +107 -0
- data/lib/brut/instrumentation.rb +4 -6
- data/lib/brut/junk_drawer.rb +54 -4
- data/lib/brut/sinatra_helpers.rb +42 -38
- data/lib/brut/spec_support/clock_support.rb +6 -0
- data/lib/brut/spec_support/component_support.rb +53 -26
- data/lib/brut/spec_support/e2e_test_server.rb +82 -0
- data/lib/brut/spec_support/enhanced_node.rb +45 -0
- data/lib/brut/spec_support/general_support.rb +14 -3
- data/lib/brut/spec_support/handler_support.rb +2 -0
- data/lib/brut/spec_support/matcher.rb +6 -3
- data/lib/brut/spec_support/matchers/be_page_for.rb +3 -2
- data/lib/brut/spec_support/matchers/have_constraint_violation.rb +22 -9
- data/lib/brut/spec_support/matchers/have_html_attribute.rb +9 -2
- data/lib/brut/spec_support/matchers/have_i18n_string.rb +24 -0
- data/lib/brut/spec_support/matchers/have_link_to.rb +14 -0
- data/lib/brut/spec_support/matchers/have_redirected_to.rb +30 -0
- data/lib/brut/spec_support/matchers/have_rendered.rb +4 -11
- data/lib/brut/spec_support/matchers/have_returned_http_status.rb +16 -8
- data/lib/brut/spec_support/rspec_setup.rb +182 -0
- data/lib/brut/spec_support.rb +8 -3
- data/lib/brut/version.rb +2 -1
- data/lib/brut.rb +28 -5
- data/lib/sequel/extensions/brut_instrumentation.rb +5 -27
- data/lib/sequel/extensions/brut_migrations.rb +18 -8
- data/lib/sequel/plugins/created_at.rb +2 -0
- data/lib/sequel/plugins/external_id.rb +39 -1
- data/lib/sequel/plugins/find_bang.rb +4 -1
- metadata +140 -13
- data/lib/brut/back_end/action.rb +0 -3
- data/lib/brut/back_end/result.rb +0 -46
- data/lib/brut/front_end/components/timestamp.rb +0 -33
- data/lib/brut/instrumentation/basic.rb +0 -66
- data/lib/brut/instrumentation/event.rb +0 -19
- data/lib/brut/instrumentation/http_event.rb +0 -5
- data/lib/brut/instrumentation/subscriber.rb +0 -41
data/lib/brut/cli/command.rb
CHANGED
@@ -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
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
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
|
-
|
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
|
data/lib/brut/cli/error.rb
CHANGED
@@ -1,12 +1,39 @@
|
|
1
|
-
#
|
2
|
-
#
|
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
|
-
|
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
|
-
#
|
21
|
+
# @return [true|false] true if execution internal to the command should stop
|
12
22
|
def stop? = @exit_status != 0
|
13
|
-
#
|
23
|
+
# @return [true|false] true if the execution of the command succeeded or didn't error
|
14
24
|
def ok? = @exit_status == 0
|
15
|
-
#
|
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
|
-
#
|
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
|
-
#
|
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
|
-
#
|
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)
|
data/lib/brut/cli/executor.rb
CHANGED
@@ -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(
|
53
|
+
raise Brut::CLI::SystemExecError.new(args,wait_thread.value.exitstatus)
|
34
54
|
end
|
35
55
|
true
|
36
56
|
end
|
data/lib/brut/cli/options.rb
CHANGED
@@ -1,19 +1,46 @@
|
|
1
|
-
#
|
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 =~ /\?$/
|
data/lib/brut/cli/output.rb
CHANGED
@@ -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")
|
data/lib/brut/factory_bot.rb
CHANGED
@@ -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 = [
|