gorails 0.1.0 → 0.1.3

Sign up to get free protection for your applications and to get access to all the features.
Files changed (74) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -1
  3. data/Gemfile +3 -1
  4. data/Gemfile.lock +65 -0
  5. data/README.md +41 -12
  6. data/bin/update-deps +95 -0
  7. data/exe/gorails +18 -0
  8. data/gorails.gemspec +4 -3
  9. data/lib/gorails/commands/episodes.rb +25 -0
  10. data/lib/gorails/commands/example.rb +19 -0
  11. data/lib/gorails/commands/help.rb +21 -0
  12. data/lib/gorails/commands/jobs.rb +25 -0
  13. data/lib/gorails/commands/jumpstart.rb +29 -0
  14. data/lib/gorails/commands/railsbytes.rb +67 -0
  15. data/lib/gorails/commands.rb +19 -0
  16. data/lib/gorails/entry_point.rb +10 -0
  17. data/lib/gorails/version.rb +1 -1
  18. data/lib/gorails.rb +22 -1
  19. data/vendor/deps/cli-kit/REVISION +1 -0
  20. data/vendor/deps/cli-kit/lib/cli/kit/args/definition.rb +301 -0
  21. data/vendor/deps/cli-kit/lib/cli/kit/args/evaluation.rb +237 -0
  22. data/vendor/deps/cli-kit/lib/cli/kit/args/parser/node.rb +131 -0
  23. data/vendor/deps/cli-kit/lib/cli/kit/args/parser.rb +128 -0
  24. data/vendor/deps/cli-kit/lib/cli/kit/args/tokenizer.rb +132 -0
  25. data/vendor/deps/cli-kit/lib/cli/kit/args.rb +15 -0
  26. data/vendor/deps/cli-kit/lib/cli/kit/base_command.rb +29 -0
  27. data/vendor/deps/cli-kit/lib/cli/kit/command_help.rb +256 -0
  28. data/vendor/deps/cli-kit/lib/cli/kit/command_registry.rb +141 -0
  29. data/vendor/deps/cli-kit/lib/cli/kit/config.rb +137 -0
  30. data/vendor/deps/cli-kit/lib/cli/kit/core_ext.rb +30 -0
  31. data/vendor/deps/cli-kit/lib/cli/kit/error_handler.rb +165 -0
  32. data/vendor/deps/cli-kit/lib/cli/kit/executor.rb +99 -0
  33. data/vendor/deps/cli-kit/lib/cli/kit/ini.rb +94 -0
  34. data/vendor/deps/cli-kit/lib/cli/kit/levenshtein.rb +89 -0
  35. data/vendor/deps/cli-kit/lib/cli/kit/logger.rb +95 -0
  36. data/vendor/deps/cli-kit/lib/cli/kit/opts.rb +284 -0
  37. data/vendor/deps/cli-kit/lib/cli/kit/resolver.rb +67 -0
  38. data/vendor/deps/cli-kit/lib/cli/kit/sorbet_runtime_stub.rb +142 -0
  39. data/vendor/deps/cli-kit/lib/cli/kit/support/test_helper.rb +253 -0
  40. data/vendor/deps/cli-kit/lib/cli/kit/support.rb +10 -0
  41. data/vendor/deps/cli-kit/lib/cli/kit/system.rb +350 -0
  42. data/vendor/deps/cli-kit/lib/cli/kit/util.rb +133 -0
  43. data/vendor/deps/cli-kit/lib/cli/kit/version.rb +7 -0
  44. data/vendor/deps/cli-kit/lib/cli/kit.rb +151 -0
  45. data/vendor/deps/cli-ui/REVISION +1 -0
  46. data/vendor/deps/cli-ui/lib/cli/ui/ansi.rb +180 -0
  47. data/vendor/deps/cli-ui/lib/cli/ui/color.rb +98 -0
  48. data/vendor/deps/cli-ui/lib/cli/ui/formatter.rb +216 -0
  49. data/vendor/deps/cli-ui/lib/cli/ui/frame/frame_stack.rb +116 -0
  50. data/vendor/deps/cli-ui/lib/cli/ui/frame/frame_style/box.rb +176 -0
  51. data/vendor/deps/cli-ui/lib/cli/ui/frame/frame_style/bracket.rb +149 -0
  52. data/vendor/deps/cli-ui/lib/cli/ui/frame/frame_style.rb +112 -0
  53. data/vendor/deps/cli-ui/lib/cli/ui/frame.rb +300 -0
  54. data/vendor/deps/cli-ui/lib/cli/ui/glyph.rb +92 -0
  55. data/vendor/deps/cli-ui/lib/cli/ui/os.rb +58 -0
  56. data/vendor/deps/cli-ui/lib/cli/ui/printer.rb +72 -0
  57. data/vendor/deps/cli-ui/lib/cli/ui/progress.rb +102 -0
  58. data/vendor/deps/cli-ui/lib/cli/ui/prompt/interactive_options.rb +534 -0
  59. data/vendor/deps/cli-ui/lib/cli/ui/prompt/options_handler.rb +36 -0
  60. data/vendor/deps/cli-ui/lib/cli/ui/prompt.rb +354 -0
  61. data/vendor/deps/cli-ui/lib/cli/ui/sorbet_runtime_stub.rb +143 -0
  62. data/vendor/deps/cli-ui/lib/cli/ui/spinner/async.rb +46 -0
  63. data/vendor/deps/cli-ui/lib/cli/ui/spinner/spin_group.rb +292 -0
  64. data/vendor/deps/cli-ui/lib/cli/ui/spinner.rb +82 -0
  65. data/vendor/deps/cli-ui/lib/cli/ui/stdout_router.rb +264 -0
  66. data/vendor/deps/cli-ui/lib/cli/ui/terminal.rb +53 -0
  67. data/vendor/deps/cli-ui/lib/cli/ui/truncater.rb +107 -0
  68. data/vendor/deps/cli-ui/lib/cli/ui/version.rb +6 -0
  69. data/vendor/deps/cli-ui/lib/cli/ui/widgets/base.rb +37 -0
  70. data/vendor/deps/cli-ui/lib/cli/ui/widgets/status.rb +75 -0
  71. data/vendor/deps/cli-ui/lib/cli/ui/widgets.rb +91 -0
  72. data/vendor/deps/cli-ui/lib/cli/ui/wrap.rb +63 -0
  73. data/vendor/deps/cli-ui/lib/cli/ui.rb +356 -0
  74. metadata +114 -5
@@ -0,0 +1,253 @@
1
+ require 'cli/kit'
2
+
3
+ module CLI
4
+ module Kit
5
+ module Support
6
+ module TestHelper
7
+ def setup
8
+ super
9
+ CLI::Kit::System.reset!
10
+ end
11
+
12
+ def assert_all_commands_run(should_raise: true)
13
+ errors = CLI::Kit::System.error_message
14
+ CLI::Kit::System.reset!
15
+ # this is in minitest, but sorbet doesn't know that. probably we
16
+ # could structure this better.
17
+ T.unsafe(self).assert(false, errors) if should_raise && !errors.nil?
18
+ errors
19
+ end
20
+
21
+ def teardown
22
+ super
23
+ assert_all_commands_run
24
+ end
25
+
26
+ module FakeConfig
27
+ require 'tmpdir'
28
+ require 'fileutils'
29
+
30
+ def setup
31
+ super
32
+ @tmpdir = Dir.mktmpdir
33
+ @prev_xdg = ENV['XDG_CONFIG_HOME']
34
+ ENV['XDG_CONFIG_HOME'] = @tmpdir
35
+ end
36
+
37
+ def teardown
38
+ FileUtils.rm_rf(@tmpdir)
39
+ ENV['XDG_CONFIG_HOME'] = @prev_xdg
40
+ super
41
+ end
42
+ end
43
+
44
+ class FakeSuccess
45
+ def initialize(success)
46
+ @success = success
47
+ end
48
+
49
+ def success?
50
+ @success
51
+ end
52
+ end
53
+
54
+ module ::CLI
55
+ module Kit
56
+ module System
57
+ class << self
58
+ alias_method :original_system, :system
59
+ def system(cmd, *a, sudo: false, env: {}, **kwargs)
60
+ a.unshift(cmd)
61
+ expected_command = expected_command(a, sudo: sudo, env: env)
62
+
63
+ # In the case of an unexpected command, expected_command will be nil
64
+ return FakeSuccess.new(false) if expected_command.nil?
65
+
66
+ # Otherwise handle the command
67
+ if expected_command[:allow]
68
+ T.unsafe(self).original_system(*a, sudo: sudo, env: env, **kwargs)
69
+ else
70
+ FakeSuccess.new(expected_command[:success])
71
+ end
72
+ end
73
+
74
+ alias_method :original_capture2, :capture2
75
+ def capture2(cmd, *a, sudo: false, env: {}, **kwargs)
76
+ a.unshift(cmd)
77
+ expected_command = expected_command(a, sudo: sudo, env: env)
78
+
79
+ # In the case of an unexpected command, expected_command will be nil
80
+ return [nil, FakeSuccess.new(false)] if expected_command.nil?
81
+
82
+ # Otherwise handle the command
83
+ if expected_command[:allow]
84
+ T.unsafe(self).original_capture2(*a, sudo: sudo, env: env, **kwargs)
85
+ else
86
+ [
87
+ expected_command[:stdout],
88
+ FakeSuccess.new(expected_command[:success]),
89
+ ]
90
+ end
91
+ end
92
+
93
+ alias_method :original_capture2e, :capture2e
94
+ def capture2e(cmd, *a, sudo: false, env: {}, **kwargs)
95
+ a.unshift(cmd)
96
+ expected_command = expected_command(a, sudo: sudo, env: env)
97
+
98
+ # In the case of an unexpected command, expected_command will be nil
99
+ return [nil, FakeSuccess.new(false)] if expected_command.nil?
100
+
101
+ # Otherwise handle the command
102
+ if expected_command[:allow]
103
+ T.unsafe(self).original_capture2e(*a, sudo: sudo, env: env, **kwargs)
104
+ else
105
+ [
106
+ expected_command[:stdout],
107
+ FakeSuccess.new(expected_command[:success]),
108
+ ]
109
+ end
110
+ end
111
+
112
+ alias_method :original_capture3, :capture3
113
+ def capture3(cmd, *a, sudo: false, env: {}, **kwargs)
114
+ a.unshift(cmd)
115
+ expected_command = expected_command(a, sudo: sudo, env: env)
116
+
117
+ # In the case of an unexpected command, expected_command will be nil
118
+ return [nil, nil, FakeSuccess.new(false)] if expected_command.nil?
119
+
120
+ # Otherwise handle the command
121
+ if expected_command[:allow]
122
+ T.unsafe(self).original_capture3(*a, sudo: sudo, env: env, **kwargs)
123
+ else
124
+ [
125
+ expected_command[:stdout],
126
+ expected_command[:stderr],
127
+ FakeSuccess.new(expected_command[:success]),
128
+ ]
129
+ end
130
+ end
131
+
132
+ # Sets up an expectation for a command and stubs out the call (unless allow is true)
133
+ #
134
+ # #### Parameters
135
+ # `*a` : the command, represented as a splat
136
+ # `stdout` : stdout to stub the command with (defaults to empty string)
137
+ # `stderr` : stderr to stub the command with (defaults to empty string)
138
+ # `allow` : allow determines if the command will be actually run, or stubbed. Defaults to nil (stub)
139
+ # `success` : success status to stub the command with (Defaults to nil)
140
+ # `sudo` : expectation of sudo being set or not (defaults to false)
141
+ # `env` : expectation of env being set or not (defaults to {})
142
+ #
143
+ # Note: Must set allow or success
144
+ #
145
+ def fake(*a, stdout: '', stderr: '', allow: nil, success: nil, sudo: false, env: {})
146
+ raise ArgumentError, 'success or allow must be set' if success.nil? && allow.nil?
147
+
148
+ @delegate_open3 ||= {}
149
+ @delegate_open3[a.join(' ')] = {
150
+ expected: {
151
+ sudo: sudo,
152
+ env: env,
153
+ },
154
+ actual: {
155
+ sudo: nil,
156
+ env: nil,
157
+ },
158
+ stdout: stdout,
159
+ stderr: stderr,
160
+ allow: allow,
161
+ success: success,
162
+ run: false,
163
+ }
164
+ end
165
+
166
+ # Resets the faked commands
167
+ #
168
+ def reset!
169
+ @delegate_open3 = {}
170
+ end
171
+
172
+ # Returns the errors associated to a test run
173
+ #
174
+ # #### Returns
175
+ # `errors` (String) a string representing errors found on this run, nil if none
176
+ def error_message
177
+ errors = {
178
+ unexpected: [],
179
+ not_run: [],
180
+ other: {},
181
+ }
182
+
183
+ @delegate_open3.each do |cmd, opts|
184
+ if opts[:unexpected]
185
+ errors[:unexpected] << cmd
186
+ elsif opts[:run]
187
+ error = []
188
+
189
+ if opts[:expected][:sudo] != opts[:actual][:sudo]
190
+ error << "- sudo was supposed to be #{opts[:expected][:sudo]} but was #{opts[:actual][:sudo]}"
191
+ end
192
+
193
+ if opts[:expected][:env] != opts[:actual][:env]
194
+ error << "- env was supposed to be #{opts[:expected][:env]} but was #{opts[:actual][:env]}"
195
+ end
196
+
197
+ errors[:other][cmd] = error.join("\n") unless error.empty?
198
+ else
199
+ errors[:not_run] << cmd
200
+ end
201
+ end
202
+
203
+ final_error = []
204
+
205
+ unless errors[:unexpected].empty?
206
+ final_error << CLI::UI.fmt(<<~EOF)
207
+ {{bold:Unexpected command invocations:}}
208
+ {{command:#{errors[:unexpected].join("\n")}}}
209
+ EOF
210
+ end
211
+
212
+ unless errors[:not_run].empty?
213
+ final_error << CLI::UI.fmt(<<~EOF)
214
+ {{bold:Expected commands were not run:}}
215
+ {{command:#{errors[:not_run].join("\n")}}}
216
+ EOF
217
+ end
218
+
219
+ unless errors[:other].empty?
220
+ final_error << CLI::UI.fmt(<<~EOF)
221
+ {{bold:Commands were not run as expected:}}
222
+ #{errors[:other].map { |cmd, msg| "{{command:#{cmd}}}\n#{msg}" }.join("\n\n")}
223
+ EOF
224
+ end
225
+
226
+ return nil if final_error.empty?
227
+
228
+ "\n" + final_error.join("\n") # Initial new line for formatting reasons
229
+ end
230
+
231
+ private
232
+
233
+ def expected_command(a, sudo: raise, env: raise)
234
+ expected_cmd = @delegate_open3[a.join(' ')]
235
+
236
+ if expected_cmd.nil?
237
+ @delegate_open3[a.join(' ')] = { unexpected: true }
238
+ return nil
239
+ end
240
+
241
+ expected_cmd[:run] = true
242
+ expected_cmd[:actual][:sudo] = sudo
243
+ expected_cmd[:actual][:env] = env
244
+ expected_cmd
245
+ end
246
+ end
247
+ end
248
+ end
249
+ end
250
+ end
251
+ end
252
+ end
253
+ end
@@ -0,0 +1,10 @@
1
+ # typed: true
2
+ require 'cli/kit'
3
+
4
+ module CLI
5
+ module Kit
6
+ module Support
7
+ autoload :TestHelper, 'cli/kit/support/test_helper'
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,350 @@
1
+ # typed: true
2
+ require 'cli/kit'
3
+ require 'open3'
4
+ require 'English'
5
+
6
+ module CLI
7
+ module Kit
8
+ module System
9
+ SUDO_PROMPT = CLI::UI.fmt('{{info:(sudo)}} Password: ')
10
+ class << self
11
+ extend T::Sig
12
+
13
+ # Ask for sudo access with a message explaning the need for it
14
+ # Will make subsequent commands capable of running with sudo for a period of time
15
+ #
16
+ # #### Parameters
17
+ # - `msg`: A message telling the user why sudo is needed
18
+ #
19
+ # #### Usage
20
+ # `ctx.sudo_reason("We need to do a thing")`
21
+ #
22
+ sig { params(msg: String).void }
23
+ def sudo_reason(msg)
24
+ # See if sudo has a cached password
25
+ %x(env SUDO_ASKPASS=/usr/bin/false sudo -A true > /dev/null 2>&1)
26
+ return if $CHILD_STATUS.success?
27
+
28
+ CLI::UI.with_frame_color(:blue) do
29
+ puts(CLI::UI.fmt("{{i}} #{msg}"))
30
+ end
31
+ end
32
+
33
+ # Execute a command in the user's environment
34
+ # This is meant to be largely equivalent to backticks, only with the env passed in.
35
+ # Captures the results of the command without output to the console
36
+ #
37
+ # #### Parameters
38
+ # - `*a`: A splat of arguments evaluated as a command. (e.g. `'rm', folder` is equivalent to `rm #{folder}`)
39
+ # - `sudo`: If truthy, run this command with sudo. If String, pass to `sudo_reason`
40
+ # - `env`: process environment with which to execute this command
41
+ # - `**kwargs`: additional arguments to pass to Open3.capture2
42
+ #
43
+ # #### Returns
44
+ # - `output`: output (STDOUT) of the command execution
45
+ # - `status`: boolean success status of the command execution
46
+ #
47
+ # #### Usage
48
+ # `out, stat = CLI::Kit::System.capture2('ls', 'a_folder')`
49
+ #
50
+ sig do
51
+ params(
52
+ cmd: String,
53
+ args: String,
54
+ sudo: T.any(T::Boolean, String),
55
+ env: T::Hash[String, T.nilable(String)],
56
+ kwargs: T.untyped
57
+ )
58
+ .returns([String, Process::Status])
59
+ end
60
+ def capture2(cmd, *args, sudo: false, env: ENV.to_h, **kwargs)
61
+ delegate_open3(cmd, args, kwargs, sudo: sudo, env: env, method: :capture2)
62
+ end
63
+
64
+ # Execute a command in the user's environment
65
+ # This is meant to be largely equivalent to backticks, only with the env passed in.
66
+ # Captures the results of the command without output to the console
67
+ #
68
+ # #### Parameters
69
+ # - `*a`: A splat of arguments evaluated as a command. (e.g. `'rm', folder` is equivalent to `rm #{folder}`)
70
+ # - `sudo`: If truthy, run this command with sudo. If String, pass to `sudo_reason`
71
+ # - `env`: process environment with which to execute this command
72
+ # - `**kwargs`: additional arguments to pass to Open3.capture2e
73
+ #
74
+ # #### Returns
75
+ # - `output`: output (STDOUT merged with STDERR) of the command execution
76
+ # - `status`: boolean success status of the command execution
77
+ #
78
+ # #### Usage
79
+ # `out_and_err, stat = CLI::Kit::System.capture2e('ls', 'a_folder')`
80
+ #
81
+ sig do
82
+ params(
83
+ cmd: String,
84
+ args: String,
85
+ sudo: T.any(T::Boolean, String),
86
+ env: T::Hash[String, T.nilable(String)],
87
+ kwargs: T.untyped
88
+ )
89
+ .returns([String, Process::Status])
90
+ end
91
+ def capture2e(cmd, *args, sudo: false, env: ENV.to_h, **kwargs)
92
+ delegate_open3(cmd, args, kwargs, sudo: sudo, env: env, method: :capture2e)
93
+ end
94
+
95
+ # Execute a command in the user's environment
96
+ # This is meant to be largely equivalent to backticks, only with the env passed in.
97
+ # Captures the results of the command without output to the console
98
+ #
99
+ # #### Parameters
100
+ # - `*a`: A splat of arguments evaluated as a command. (e.g. `'rm', folder` is equivalent to `rm #{folder}`)
101
+ # - `sudo`: If truthy, run this command with sudo. If String, pass to `sudo_reason`
102
+ # - `env`: process environment with which to execute this command
103
+ # - `**kwargs`: additional arguments to pass to Open3.capture3
104
+ #
105
+ # #### Returns
106
+ # - `output`: STDOUT of the command execution
107
+ # - `error`: STDERR of the command execution
108
+ # - `status`: boolean success status of the command execution
109
+ #
110
+ # #### Usage
111
+ # `out, err, stat = CLI::Kit::System.capture3('ls', 'a_folder')`
112
+ #
113
+ sig do
114
+ params(
115
+ cmd: String,
116
+ args: String,
117
+ sudo: T.any(T::Boolean, String),
118
+ env: T::Hash[String, T.nilable(String)],
119
+ kwargs: T.untyped
120
+ )
121
+ .returns([String, String, Process::Status])
122
+ end
123
+ def capture3(cmd, *args, sudo: false, env: ENV.to_h, **kwargs)
124
+ delegate_open3(cmd, args, kwargs, sudo: sudo, env: env, method: :capture3)
125
+ end
126
+
127
+ sig do
128
+ params(
129
+ cmd: String,
130
+ args: String,
131
+ sudo: T.any(T::Boolean, String),
132
+ env: T::Hash[String, T.nilable(String)],
133
+ kwargs: T.untyped,
134
+ block: T.nilable(
135
+ T.proc.params(stdin: IO, stdout: IO, wait_thr: Process::Waiter)
136
+ .returns([IO, IO, Process::Waiter])
137
+ )
138
+ )
139
+ .returns([IO, IO, Process::Waiter])
140
+ end
141
+ def popen2(cmd, *args, sudo: false, env: ENV.to_h, **kwargs, &block)
142
+ delegate_open3(cmd, args, kwargs, sudo: sudo, env: env, method: :popen2, &block)
143
+ end
144
+
145
+ sig do
146
+ params(
147
+ cmd: String,
148
+ args: String,
149
+ sudo: T.any(T::Boolean, String),
150
+ env: T::Hash[String, T.nilable(String)],
151
+ kwargs: T.untyped,
152
+ block: T.nilable(
153
+ T.proc.params(stdin: IO, stdout: IO, wait_thr: Process::Waiter)
154
+ .returns([IO, IO, Process::Waiter])
155
+ )
156
+ )
157
+ .returns([IO, IO, Process::Waiter])
158
+ end
159
+ def popen2e(cmd, *args, sudo: false, env: ENV.to_h, **kwargs, &block)
160
+ delegate_open3(cmd, args, kwargs, sudo: sudo, env: env, method: :popen2e, &block)
161
+ end
162
+
163
+ sig do
164
+ params(
165
+ cmd: String,
166
+ args: String,
167
+ sudo: T.any(T::Boolean, String),
168
+ env: T::Hash[String, T.nilable(String)],
169
+ kwargs: T.untyped,
170
+ block: T.nilable(
171
+ T.proc.params(stdin: IO, stdout: IO, stderr: IO, wait_thr: Process::Waiter)
172
+ .returns([IO, IO, IO, Process::Waiter])
173
+ )
174
+ )
175
+ .returns([IO, IO, IO, Process::Waiter])
176
+ end
177
+ def popen3(cmd, *args, sudo: false, env: ENV.to_h, **kwargs, &block)
178
+ delegate_open3(cmd, args, kwargs, sudo: sudo, env: env, method: :popen3, &block)
179
+ end
180
+
181
+ # Execute a command in the user's environment
182
+ # Outputs result of the command without capturing it
183
+ #
184
+ # #### Parameters
185
+ # - `*a`: A splat of arguments evaluated as a command. (e.g. `'rm', folder` is equivalent to `rm #{folder}`)
186
+ # - `sudo`: If truthy, run this command with sudo. If String, pass to `sudo_reason`
187
+ # - `env`: process environment with which to execute this command
188
+ # - `**kwargs`: additional keyword arguments to pass to Process.spawn
189
+ #
190
+ # #### Returns
191
+ # - `status`: The `Process:Status` result for the command execution
192
+ #
193
+ # #### Usage
194
+ # `stat = CLI::Kit::System.system('ls', 'a_folder')`
195
+ #
196
+ sig do
197
+ params(
198
+ cmd: String,
199
+ args: String,
200
+ sudo: T.any(T::Boolean, String),
201
+ env: T::Hash[String, T.nilable(String)],
202
+ kwargs: T.untyped,
203
+ block: T.nilable(T.proc.params(out: String, err: String).void)
204
+ )
205
+ .returns(Process::Status)
206
+ end
207
+ def system(cmd, *args, sudo: false, env: ENV.to_h, **kwargs, &block)
208
+ cmd, args = apply_sudo(cmd, args, sudo)
209
+
210
+ out_r, out_w = IO.pipe
211
+ err_r, err_w = IO.pipe
212
+ in_stream = STDIN.closed? ? :close : STDIN
213
+ cmd, args = resolve_path(cmd, args, env)
214
+ pid = T.unsafe(Process).spawn(env, cmd, *args, 0 => in_stream, :out => out_w, :err => err_w, **kwargs)
215
+ out_w.close
216
+ err_w.close
217
+
218
+ handlers = if block_given?
219
+ {
220
+ out_r => ->(data) { yield(data.force_encoding(Encoding::UTF_8), '') },
221
+ err_r => ->(data) { yield('', data.force_encoding(Encoding::UTF_8)) },
222
+ }
223
+ else
224
+ {
225
+ out_r => ->(data) { STDOUT.write(data) },
226
+ err_r => ->(data) { STDOUT.write(data) },
227
+ }
228
+ end
229
+
230
+ previous_trailing = Hash.new('')
231
+ loop do
232
+ ios = [err_r, out_r].reject(&:closed?)
233
+ break if ios.empty?
234
+
235
+ readers, = IO.select(ios)
236
+ (readers || []).each do |io|
237
+ data, trailing = split_partial_characters(io.readpartial(4096))
238
+ handlers[io].call(previous_trailing[io] + data)
239
+ previous_trailing[io] = trailing
240
+ rescue IOError
241
+ io.close
242
+ end
243
+ end
244
+
245
+ Process.wait(pid)
246
+ $CHILD_STATUS
247
+ end
248
+
249
+ # Split off trailing partial UTF-8 Characters. UTF-8 Multibyte characters start with a 11xxxxxx byte that tells
250
+ # how many following bytes are part of this character, followed by some number of 10xxxxxx bytes. This simple
251
+ # algorithm will split off a whole trailing multi-byte character.
252
+ sig { params(data: String).returns([String, String]) }
253
+ def split_partial_characters(data)
254
+ last_byte = T.must(data.getbyte(-1))
255
+ return [data, ''] if (last_byte & 0b1000_0000).zero?
256
+
257
+ # UTF-8 is up to 6 characters per rune, so we could never want to trim more than that, and we want to avoid
258
+ # allocating an array for the whole of data with bytes
259
+ min_bound = -[6, data.bytesize].min
260
+ final_bytes = T.must(data.byteslice(min_bound..-1)).bytes
261
+ partial_character_sub_index = final_bytes.rindex { |byte| byte & 0b1100_0000 == 0b1100_0000 }
262
+ # Bail out for non UTF-8
263
+ return [data, ''] unless partial_character_sub_index
264
+
265
+ partial_character_index = min_bound + partial_character_sub_index
266
+
267
+ [T.must(data.byteslice(0...partial_character_index)), T.must(data.byteslice(partial_character_index..-1))]
268
+ end
269
+
270
+ sig { returns(Symbol) }
271
+ def os
272
+ return :mac if /darwin/.match(RUBY_PLATFORM)
273
+ return :linux if /linux/.match(RUBY_PLATFORM)
274
+ return :windows if /mingw32/.match(RUBY_PLATFORM)
275
+
276
+ raise "Could not determine OS from platform #{RUBY_PLATFORM}"
277
+ end
278
+
279
+ private
280
+
281
+ sig do
282
+ params(cmd: String, args: T::Array[String], sudo: T.any(T::Boolean, String))
283
+ .returns([String, T::Array[String]])
284
+ end
285
+ def apply_sudo(cmd, args, sudo)
286
+ return [cmd, args] unless sudo
287
+
288
+ sudo_reason(sudo) if sudo.is_a?(String)
289
+ ['sudo', args.unshift('-S', '-p', SUDO_PROMPT, '--', cmd)]
290
+ end
291
+
292
+ sig do
293
+ params(
294
+ cmd: String,
295
+ args: T::Array[String],
296
+ kwargs: T::Hash[Symbol, T.untyped],
297
+ sudo: T.any(T::Boolean, String),
298
+ env: T::Hash[String, T.nilable(String)],
299
+ method: Symbol,
300
+ block: T.untyped
301
+ ).returns(T.untyped)
302
+ end
303
+ def delegate_open3(cmd, args, kwargs, sudo: raise, env: raise, method: raise, &block)
304
+ cmd, args = apply_sudo(cmd, args, sudo)
305
+ cmd, args = resolve_path(cmd, args, env)
306
+ T.unsafe(Open3).send(method, env, cmd, *args, **kwargs, &block)
307
+ rescue Errno::EINTR
308
+ raise(Errno::EINTR, "command interrupted: #{cmd} #{args.join(" ")}")
309
+ end
310
+
311
+ # Ruby resolves the program to execute using its own PATH, but we want it to
312
+ # use the provided one, so we ensure ruby chooses to spawn a shell, which will
313
+ # parse our command and properly spawn our target using the provided environment.
314
+ #
315
+ # This is important because dev clobbers its own environment such that ruby
316
+ # means /usr/bin/ruby, but we want it to select the ruby targeted by the active
317
+ # project.
318
+ #
319
+ # See https://github.com/Shopify/dev/pull/625 for more details.
320
+ sig do
321
+ params(cmd: String, args: T::Array[String], env: T::Hash[String, T.nilable(String)])
322
+ .returns([String, T::Array[String]])
323
+ end
324
+ def resolve_path(cmd, args, env)
325
+ # If only one argument was provided, make sure it's interpreted by a shell.
326
+ if args.empty?
327
+ prefix = os == :windows ? 'break && ' : 'true ; '
328
+ return [prefix + cmd, []]
329
+ end
330
+ return [cmd, args] if cmd.include?('/')
331
+
332
+ [which(cmd, env) || cmd, args]
333
+ end
334
+
335
+ sig { params(cmd: T.untyped, env: T.untyped).returns(T.untyped) }
336
+ def which(cmd, env)
337
+ exts = os == :windows ? env.fetch('PATHEXT').split(';') : ['']
338
+ env.fetch('PATH', '').split(File::PATH_SEPARATOR).each do |path|
339
+ exts.each do |ext|
340
+ exe = File.join(path, "#{cmd}#{ext}")
341
+ return exe if File.executable?(exe) && !File.directory?(exe)
342
+ end
343
+ end
344
+
345
+ nil
346
+ end
347
+ end
348
+ end
349
+ end
350
+ end