cli-kit 3.3.0 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/dependabot.yml +10 -0
- data/.github/workflows/cla.yml +22 -0
- data/.github/workflows/ruby.yml +64 -0
- data/.gitignore +2 -0
- data/.rubocop.sorbet.yml +47 -0
- data/.rubocop.yml +22 -13
- data/Gemfile +13 -3
- data/Gemfile.lock +110 -28
- data/README.md +46 -3
- data/Rakefile +28 -1
- data/bin/console +3 -3
- data/bin/onchange +30 -0
- data/bin/tapioca +29 -0
- data/bin/test_gen +4 -1
- data/bin/testunit +3 -2
- data/cli-kit.gemspec +7 -6
- data/dev.yml +35 -3
- data/examples/minimal/example.rb +5 -3
- data/examples/single-file/example.rb +25 -35
- data/gen/lib/gen/commands/help.rb +8 -10
- data/gen/lib/gen/commands/new.rb +23 -9
- data/gen/lib/gen/commands.rb +21 -9
- data/gen/lib/gen/entry_point.rb +12 -3
- data/gen/lib/gen/generator.rb +39 -18
- data/gen/lib/gen/help.rb +63 -0
- data/gen/lib/gen.rb +18 -23
- data/gen/template/bin/update-deps +2 -2
- data/gen/template/lib/__app__/commands.rb +1 -4
- data/gen/template/lib/__app__.rb +8 -17
- data/gen/template/test/example_test.rb +1 -1
- data/lib/cli/kit/args/definition.rb +344 -0
- data/lib/cli/kit/args/evaluation.rb +245 -0
- data/lib/cli/kit/args/parser/node.rb +132 -0
- data/lib/cli/kit/args/parser.rb +129 -0
- data/lib/cli/kit/args/tokenizer.rb +133 -0
- data/lib/cli/kit/args.rb +16 -0
- data/lib/cli/kit/base_command.rb +17 -32
- data/lib/cli/kit/command_help.rb +271 -0
- data/lib/cli/kit/command_registry.rb +69 -17
- data/lib/cli/kit/config.rb +30 -25
- data/lib/cli/kit/core_ext.rb +30 -0
- data/lib/cli/kit/error_handler.rb +134 -67
- data/lib/cli/kit/executor.rb +39 -20
- data/lib/cli/kit/ini.rb +32 -39
- data/lib/cli/kit/levenshtein.rb +12 -4
- data/lib/cli/kit/logger.rb +23 -3
- data/lib/cli/kit/opts.rb +301 -0
- data/lib/cli/kit/resolver.rb +10 -2
- data/lib/cli/kit/sorbet_runtime_stub.rb +156 -0
- data/lib/cli/kit/support/test_helper.rb +31 -22
- data/lib/cli/kit/support.rb +2 -0
- data/lib/cli/kit/system.rb +217 -48
- data/lib/cli/kit/util.rb +52 -107
- data/lib/cli/kit/version.rb +3 -1
- data/lib/cli/kit.rb +104 -8
- metadata +35 -22
- data/.github/probots.yml +0 -2
- data/.travis.yml +0 -14
- data/lib/cli/kit/autocall.rb +0 -21
- data/lib/cli/kit/ruby_backports/enumerable.rb +0 -6
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# typed: ignore
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module T
|
|
5
|
+
class << self
|
|
6
|
+
def absurd(value); end
|
|
7
|
+
def all(type_a, type_b, *types); end
|
|
8
|
+
def any(type_a, type_b, *types); end
|
|
9
|
+
def attached_class; end
|
|
10
|
+
def class_of(klass); end
|
|
11
|
+
def enum(values); end
|
|
12
|
+
def nilable(type); end
|
|
13
|
+
def noreturn; end
|
|
14
|
+
def self_type; end
|
|
15
|
+
def type_alias(type = nil, &_blk); end
|
|
16
|
+
def type_parameter(name); end
|
|
17
|
+
def untyped; end
|
|
18
|
+
|
|
19
|
+
def assert_type!(value, _type, _checked: true)
|
|
20
|
+
value
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def cast(value, _type, _checked: true)
|
|
24
|
+
value
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def let(value, _type, _checked: true)
|
|
28
|
+
value
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def must(arg, _msg = nil)
|
|
32
|
+
arg
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def proc
|
|
36
|
+
T::Proc.new
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def reveal_type(value)
|
|
40
|
+
value
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def unsafe(value)
|
|
44
|
+
value
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
module Sig
|
|
49
|
+
def sig(arg0 = nil, &blk); end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
module Helpers
|
|
53
|
+
def abstract!; end
|
|
54
|
+
def interface!; end
|
|
55
|
+
def final!; end
|
|
56
|
+
def sealed!; end
|
|
57
|
+
def mixes_in_class_methods(mod); end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
module Generic
|
|
61
|
+
include(T::Helpers)
|
|
62
|
+
|
|
63
|
+
def type_parameters(*params); end
|
|
64
|
+
def type_member(variance = :invariant, fixed: nil, lower: nil, upper: BasicObject); end
|
|
65
|
+
def type_template(variance = :invariant, fixed: nil, lower: nil, upper: BasicObject); end
|
|
66
|
+
|
|
67
|
+
def [](*types)
|
|
68
|
+
self
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
module Array
|
|
73
|
+
class << self
|
|
74
|
+
def [](type); end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
Boolean = Object.new.freeze
|
|
79
|
+
|
|
80
|
+
module Configuration
|
|
81
|
+
class << self
|
|
82
|
+
def call_validation_error_handler(signature, opts); end
|
|
83
|
+
def call_validation_error_handler=(value); end
|
|
84
|
+
def default_checked_level=(default_checked_level); end
|
|
85
|
+
def enable_checking_for_sigs_marked_checked_tests; end
|
|
86
|
+
def enable_final_checks_on_hooks; end
|
|
87
|
+
def enable_legacy_t_enum_migration_mode; end
|
|
88
|
+
def reset_final_checks_on_hooks; end
|
|
89
|
+
def hard_assert_handler(str, extra); end
|
|
90
|
+
def hard_assert_handler=(value); end
|
|
91
|
+
def inline_type_error_handler(error); end
|
|
92
|
+
def inline_type_error_handler=(value); end
|
|
93
|
+
def log_info_handler(str, extra); end
|
|
94
|
+
def log_info_handler=(value); end
|
|
95
|
+
def scalar_types; end
|
|
96
|
+
def scalar_types=(values); end
|
|
97
|
+
# rubocop:disable Naming/InclusiveLanguage
|
|
98
|
+
def sealed_violation_whitelist; end
|
|
99
|
+
def sealed_violation_whitelist=(sealed_violation_whitelist); end
|
|
100
|
+
# rubocop:enable Naming/InclusiveLanguage
|
|
101
|
+
def sig_builder_error_handler=(value); end
|
|
102
|
+
def sig_validation_error_handler(error, opts); end
|
|
103
|
+
def sig_validation_error_handler=(value); end
|
|
104
|
+
def soft_assert_handler(str, extra); end
|
|
105
|
+
def soft_assert_handler=(value); end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
module Enumerable
|
|
110
|
+
class << self
|
|
111
|
+
def [](type); end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
module Enumerator
|
|
116
|
+
class << self
|
|
117
|
+
def [](type); end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
module Hash
|
|
122
|
+
class << self
|
|
123
|
+
def [](keys, values); end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
class Proc
|
|
128
|
+
def bind(*_)
|
|
129
|
+
self
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def params(*_param)
|
|
133
|
+
self
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def void
|
|
137
|
+
self
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def returns(_type)
|
|
141
|
+
self
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
module Range
|
|
146
|
+
class << self
|
|
147
|
+
def [](type); end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
module Set
|
|
152
|
+
class << self
|
|
153
|
+
def [](type); end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require 'cli/kit'
|
|
2
|
+
|
|
1
3
|
module CLI
|
|
2
4
|
module Kit
|
|
3
5
|
module Support
|
|
@@ -10,7 +12,9 @@ module CLI
|
|
|
10
12
|
def assert_all_commands_run(should_raise: true)
|
|
11
13
|
errors = CLI::Kit::System.error_message
|
|
12
14
|
CLI::Kit::System.reset!
|
|
13
|
-
|
|
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?
|
|
14
18
|
errors
|
|
15
19
|
end
|
|
16
20
|
|
|
@@ -52,30 +56,32 @@ module CLI
|
|
|
52
56
|
module System
|
|
53
57
|
class << self
|
|
54
58
|
alias_method :original_system, :system
|
|
55
|
-
def system(*a, sudo: false, env: {}, **kwargs)
|
|
56
|
-
|
|
59
|
+
def system(cmd, *a, sudo: false, env: {}, stdin: nil, **kwargs)
|
|
60
|
+
a.unshift(cmd)
|
|
61
|
+
expected_command = expected_command(a, sudo: sudo, env: env)
|
|
57
62
|
|
|
58
63
|
# In the case of an unexpected command, expected_command will be nil
|
|
59
64
|
return FakeSuccess.new(false) if expected_command.nil?
|
|
60
65
|
|
|
61
66
|
# Otherwise handle the command
|
|
62
67
|
if expected_command[:allow]
|
|
63
|
-
original_system(*a, sudo: sudo, env: env, **kwargs)
|
|
68
|
+
T.unsafe(self).original_system(*a, sudo: sudo, env: env, **kwargs)
|
|
64
69
|
else
|
|
65
70
|
FakeSuccess.new(expected_command[:success])
|
|
66
71
|
end
|
|
67
72
|
end
|
|
68
73
|
|
|
69
74
|
alias_method :original_capture2, :capture2
|
|
70
|
-
def capture2(*a, sudo: false, env: {}, **kwargs)
|
|
71
|
-
|
|
75
|
+
def capture2(cmd, *a, sudo: false, env: {}, **kwargs)
|
|
76
|
+
a.unshift(cmd)
|
|
77
|
+
expected_command = expected_command(a, sudo: sudo, env: env)
|
|
72
78
|
|
|
73
79
|
# In the case of an unexpected command, expected_command will be nil
|
|
74
80
|
return [nil, FakeSuccess.new(false)] if expected_command.nil?
|
|
75
81
|
|
|
76
82
|
# Otherwise handle the command
|
|
77
83
|
if expected_command[:allow]
|
|
78
|
-
original_capture2(*a, sudo: sudo, env: env, **kwargs)
|
|
84
|
+
T.unsafe(self).original_capture2(*a, sudo: sudo, env: env, **kwargs)
|
|
79
85
|
else
|
|
80
86
|
[
|
|
81
87
|
expected_command[:stdout],
|
|
@@ -85,15 +91,16 @@ module CLI
|
|
|
85
91
|
end
|
|
86
92
|
|
|
87
93
|
alias_method :original_capture2e, :capture2e
|
|
88
|
-
def capture2e(*a, sudo: false, env: {}, **kwargs)
|
|
89
|
-
|
|
94
|
+
def capture2e(cmd, *a, sudo: false, env: {}, **kwargs)
|
|
95
|
+
a.unshift(cmd)
|
|
96
|
+
expected_command = expected_command(a, sudo: sudo, env: env)
|
|
90
97
|
|
|
91
98
|
# In the case of an unexpected command, expected_command will be nil
|
|
92
99
|
return [nil, FakeSuccess.new(false)] if expected_command.nil?
|
|
93
100
|
|
|
94
101
|
# Otherwise handle the command
|
|
95
102
|
if expected_command[:allow]
|
|
96
|
-
|
|
103
|
+
T.unsafe(self).original_capture2e(*a, sudo: sudo, env: env, **kwargs)
|
|
97
104
|
else
|
|
98
105
|
[
|
|
99
106
|
expected_command[:stdout],
|
|
@@ -103,15 +110,16 @@ module CLI
|
|
|
103
110
|
end
|
|
104
111
|
|
|
105
112
|
alias_method :original_capture3, :capture3
|
|
106
|
-
def capture3(*a, sudo: false, env: {}, **kwargs)
|
|
107
|
-
|
|
113
|
+
def capture3(cmd, *a, sudo: false, env: {}, **kwargs)
|
|
114
|
+
a.unshift(cmd)
|
|
115
|
+
expected_command = expected_command(a, sudo: sudo, env: env)
|
|
108
116
|
|
|
109
117
|
# In the case of an unexpected command, expected_command will be nil
|
|
110
118
|
return [nil, nil, FakeSuccess.new(false)] if expected_command.nil?
|
|
111
119
|
|
|
112
120
|
# Otherwise handle the command
|
|
113
121
|
if expected_command[:allow]
|
|
114
|
-
original_capture3(*a, sudo: sudo, env: env, **kwargs)
|
|
122
|
+
T.unsafe(self).original_capture3(*a, sudo: sudo, env: env, **kwargs)
|
|
115
123
|
else
|
|
116
124
|
[
|
|
117
125
|
expected_command[:stdout],
|
|
@@ -134,8 +142,8 @@ module CLI
|
|
|
134
142
|
#
|
|
135
143
|
# Note: Must set allow or success
|
|
136
144
|
#
|
|
137
|
-
def fake(*a, stdout:
|
|
138
|
-
raise ArgumentError,
|
|
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?
|
|
139
147
|
|
|
140
148
|
@delegate_open3 ||= {}
|
|
141
149
|
@delegate_open3[a.join(' ')] = {
|
|
@@ -196,32 +204,33 @@ module CLI
|
|
|
196
204
|
|
|
197
205
|
unless errors[:unexpected].empty?
|
|
198
206
|
final_error << CLI::UI.fmt(<<~EOF)
|
|
199
|
-
|
|
200
|
-
|
|
207
|
+
{{bold:Unexpected command invocations:}}
|
|
208
|
+
{{command:#{errors[:unexpected].join("\n")}}}
|
|
201
209
|
EOF
|
|
202
210
|
end
|
|
203
211
|
|
|
204
212
|
unless errors[:not_run].empty?
|
|
205
213
|
final_error << CLI::UI.fmt(<<~EOF)
|
|
206
|
-
|
|
207
|
-
|
|
214
|
+
{{bold:Expected commands were not run:}}
|
|
215
|
+
{{command:#{errors[:not_run].join("\n")}}}
|
|
208
216
|
EOF
|
|
209
217
|
end
|
|
210
218
|
|
|
211
219
|
unless errors[:other].empty?
|
|
212
220
|
final_error << CLI::UI.fmt(<<~EOF)
|
|
213
|
-
|
|
214
|
-
|
|
221
|
+
{{bold:Commands were not run as expected:}}
|
|
222
|
+
#{errors[:other].map { |cmd, msg| "{{command:#{cmd}}}\n#{msg}" }.join("\n\n")}
|
|
215
223
|
EOF
|
|
216
224
|
end
|
|
217
225
|
|
|
218
226
|
return nil if final_error.empty?
|
|
227
|
+
|
|
219
228
|
"\n" + final_error.join("\n") # Initial new line for formatting reasons
|
|
220
229
|
end
|
|
221
230
|
|
|
222
231
|
private
|
|
223
232
|
|
|
224
|
-
def expected_command(
|
|
233
|
+
def expected_command(a, sudo: raise, env: raise)
|
|
225
234
|
expected_cmd = @delegate_open3[a.join(' ')]
|
|
226
235
|
|
|
227
236
|
if expected_cmd.nil?
|
data/lib/cli/kit/support.rb
CHANGED
data/lib/cli/kit/system.rb
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
|
-
|
|
1
|
+
# typed: true
|
|
2
2
|
|
|
3
|
+
require 'cli/kit'
|
|
3
4
|
require 'open3'
|
|
4
5
|
require 'English'
|
|
5
6
|
|
|
6
7
|
module CLI
|
|
7
8
|
module Kit
|
|
8
9
|
module System
|
|
9
|
-
SUDO_PROMPT = CLI::UI.fmt(
|
|
10
|
+
SUDO_PROMPT = CLI::UI.fmt('{{info:(sudo)}} Password: ')
|
|
10
11
|
class << self
|
|
12
|
+
extend T::Sig
|
|
13
|
+
|
|
11
14
|
# Ask for sudo access with a message explaning the need for it
|
|
12
15
|
# Will make subsequent commands capable of running with sudo for a period of time
|
|
13
16
|
#
|
|
@@ -17,10 +20,12 @@ module CLI
|
|
|
17
20
|
# #### Usage
|
|
18
21
|
# `ctx.sudo_reason("We need to do a thing")`
|
|
19
22
|
#
|
|
23
|
+
sig { params(msg: String).void }
|
|
20
24
|
def sudo_reason(msg)
|
|
21
25
|
# See if sudo has a cached password
|
|
22
|
-
|
|
26
|
+
%x(env SUDO_ASKPASS=/usr/bin/false sudo -A true > /dev/null 2>&1)
|
|
23
27
|
return if $CHILD_STATUS.success?
|
|
28
|
+
|
|
24
29
|
CLI::UI.with_frame_color(:blue) do
|
|
25
30
|
puts(CLI::UI.fmt("{{i}} #{msg}"))
|
|
26
31
|
end
|
|
@@ -43,8 +48,18 @@ module CLI
|
|
|
43
48
|
# #### Usage
|
|
44
49
|
# `out, stat = CLI::Kit::System.capture2('ls', 'a_folder')`
|
|
45
50
|
#
|
|
46
|
-
|
|
47
|
-
|
|
51
|
+
sig do
|
|
52
|
+
params(
|
|
53
|
+
cmd: String,
|
|
54
|
+
args: String,
|
|
55
|
+
sudo: T.any(T::Boolean, String),
|
|
56
|
+
env: T::Hash[String, T.nilable(String)],
|
|
57
|
+
kwargs: T.untyped,
|
|
58
|
+
)
|
|
59
|
+
.returns([String, Process::Status])
|
|
60
|
+
end
|
|
61
|
+
def capture2(cmd, *args, sudo: false, env: ENV.to_h, **kwargs)
|
|
62
|
+
delegate_open3(cmd, args, kwargs, sudo: sudo, env: env, method: :capture2)
|
|
48
63
|
end
|
|
49
64
|
|
|
50
65
|
# Execute a command in the user's environment
|
|
@@ -64,8 +79,18 @@ module CLI
|
|
|
64
79
|
# #### Usage
|
|
65
80
|
# `out_and_err, stat = CLI::Kit::System.capture2e('ls', 'a_folder')`
|
|
66
81
|
#
|
|
67
|
-
|
|
68
|
-
|
|
82
|
+
sig do
|
|
83
|
+
params(
|
|
84
|
+
cmd: String,
|
|
85
|
+
args: String,
|
|
86
|
+
sudo: T.any(T::Boolean, String),
|
|
87
|
+
env: T::Hash[String, T.nilable(String)],
|
|
88
|
+
kwargs: T.untyped,
|
|
89
|
+
)
|
|
90
|
+
.returns([String, Process::Status])
|
|
91
|
+
end
|
|
92
|
+
def capture2e(cmd, *args, sudo: false, env: ENV.to_h, **kwargs)
|
|
93
|
+
delegate_open3(cmd, args, kwargs, sudo: sudo, env: env, method: :capture2e)
|
|
69
94
|
end
|
|
70
95
|
|
|
71
96
|
# Execute a command in the user's environment
|
|
@@ -86,8 +111,72 @@ module CLI
|
|
|
86
111
|
# #### Usage
|
|
87
112
|
# `out, err, stat = CLI::Kit::System.capture3('ls', 'a_folder')`
|
|
88
113
|
#
|
|
89
|
-
|
|
90
|
-
|
|
114
|
+
sig do
|
|
115
|
+
params(
|
|
116
|
+
cmd: String,
|
|
117
|
+
args: String,
|
|
118
|
+
sudo: T.any(T::Boolean, String),
|
|
119
|
+
env: T::Hash[String, T.nilable(String)],
|
|
120
|
+
kwargs: T.untyped,
|
|
121
|
+
)
|
|
122
|
+
.returns([String, String, Process::Status])
|
|
123
|
+
end
|
|
124
|
+
def capture3(cmd, *args, sudo: false, env: ENV.to_h, **kwargs)
|
|
125
|
+
delegate_open3(cmd, args, kwargs, sudo: sudo, env: env, method: :capture3)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
sig do
|
|
129
|
+
params(
|
|
130
|
+
cmd: String,
|
|
131
|
+
args: String,
|
|
132
|
+
sudo: T.any(T::Boolean, String),
|
|
133
|
+
env: T::Hash[String, T.nilable(String)],
|
|
134
|
+
kwargs: T.untyped,
|
|
135
|
+
block: T.nilable(
|
|
136
|
+
T.proc.params(stdin: IO, stdout: IO, wait_thr: Process::Waiter)
|
|
137
|
+
.returns([IO, IO, Process::Waiter]),
|
|
138
|
+
),
|
|
139
|
+
)
|
|
140
|
+
.returns([IO, IO, Process::Waiter])
|
|
141
|
+
end
|
|
142
|
+
def popen2(cmd, *args, sudo: false, env: ENV.to_h, **kwargs, &block)
|
|
143
|
+
delegate_open3(cmd, args, kwargs, sudo: sudo, env: env, method: :popen2, &block)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
sig do
|
|
147
|
+
params(
|
|
148
|
+
cmd: String,
|
|
149
|
+
args: String,
|
|
150
|
+
sudo: T.any(T::Boolean, String),
|
|
151
|
+
env: T::Hash[String, T.nilable(String)],
|
|
152
|
+
kwargs: T.untyped,
|
|
153
|
+
block: T.nilable(
|
|
154
|
+
T.proc.params(stdin: IO, stdout: IO, wait_thr: Process::Waiter)
|
|
155
|
+
.returns([IO, IO, Process::Waiter]),
|
|
156
|
+
),
|
|
157
|
+
)
|
|
158
|
+
.returns([IO, IO, Process::Waiter])
|
|
159
|
+
end
|
|
160
|
+
def popen2e(cmd, *args, sudo: false, env: ENV.to_h, **kwargs, &block)
|
|
161
|
+
delegate_open3(cmd, args, kwargs, sudo: sudo, env: env, method: :popen2e, &block)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
sig do
|
|
165
|
+
params(
|
|
166
|
+
cmd: String,
|
|
167
|
+
args: String,
|
|
168
|
+
sudo: T.any(T::Boolean, String),
|
|
169
|
+
env: T::Hash[String, T.nilable(String)],
|
|
170
|
+
kwargs: T.untyped,
|
|
171
|
+
block: T.nilable(
|
|
172
|
+
T.proc.params(stdin: IO, stdout: IO, stderr: IO, wait_thr: Process::Waiter)
|
|
173
|
+
.returns([IO, IO, IO, Process::Waiter]),
|
|
174
|
+
),
|
|
175
|
+
)
|
|
176
|
+
.returns([IO, IO, IO, Process::Waiter])
|
|
177
|
+
end
|
|
178
|
+
def popen3(cmd, *args, sudo: false, env: ENV.to_h, **kwargs, &block)
|
|
179
|
+
delegate_open3(cmd, args, kwargs, sudo: sudo, env: env, method: :popen3, &block)
|
|
91
180
|
end
|
|
92
181
|
|
|
93
182
|
# Execute a command in the user's environment
|
|
@@ -100,27 +189,50 @@ module CLI
|
|
|
100
189
|
# - `**kwargs`: additional keyword arguments to pass to Process.spawn
|
|
101
190
|
#
|
|
102
191
|
# #### Returns
|
|
103
|
-
# - `status`:
|
|
192
|
+
# - `status`: The `Process:Status` result for the command execution
|
|
104
193
|
#
|
|
105
194
|
# #### Usage
|
|
106
195
|
# `stat = CLI::Kit::System.system('ls', 'a_folder')`
|
|
107
196
|
#
|
|
108
|
-
|
|
109
|
-
|
|
197
|
+
sig do
|
|
198
|
+
params(
|
|
199
|
+
cmd: String,
|
|
200
|
+
args: String,
|
|
201
|
+
sudo: T.any(T::Boolean, String),
|
|
202
|
+
env: T::Hash[String, T.nilable(String)],
|
|
203
|
+
stdin: T.nilable(T.any(IO, String, Integer, Symbol)),
|
|
204
|
+
kwargs: T.untyped,
|
|
205
|
+
block: T.nilable(T.proc.params(out: String, err: String).void),
|
|
206
|
+
)
|
|
207
|
+
.returns(Process::Status)
|
|
208
|
+
end
|
|
209
|
+
def system(cmd, *args, sudo: false, env: ENV.to_h, stdin: nil, **kwargs, &block)
|
|
210
|
+
cmd, args = apply_sudo(cmd, args, sudo)
|
|
110
211
|
|
|
111
212
|
out_r, out_w = IO.pipe
|
|
112
213
|
err_r, err_w = IO.pipe
|
|
113
|
-
in_stream =
|
|
114
|
-
|
|
214
|
+
in_stream = if stdin
|
|
215
|
+
stdin
|
|
216
|
+
elsif STDIN.closed?
|
|
217
|
+
:close
|
|
218
|
+
else
|
|
219
|
+
STDIN
|
|
220
|
+
end
|
|
221
|
+
cmd, args = resolve_path(cmd, args, env)
|
|
222
|
+
pid = T.unsafe(Process).spawn(env, cmd, *args, 0 => in_stream, :out => out_w, :err => err_w, **kwargs)
|
|
115
223
|
out_w.close
|
|
116
224
|
err_w.close
|
|
117
225
|
|
|
118
226
|
handlers = if block_given?
|
|
119
|
-
{
|
|
120
|
-
|
|
227
|
+
{
|
|
228
|
+
out_r => ->(data) { yield(data.force_encoding(Encoding::UTF_8), '') },
|
|
229
|
+
err_r => ->(data) { yield('', data.force_encoding(Encoding::UTF_8)) },
|
|
230
|
+
}
|
|
121
231
|
else
|
|
122
|
-
{
|
|
123
|
-
|
|
232
|
+
{
|
|
233
|
+
out_r => ->(data) { STDOUT.write(data) },
|
|
234
|
+
err_r => ->(data) { STDOUT.write(data) },
|
|
235
|
+
}
|
|
124
236
|
end
|
|
125
237
|
|
|
126
238
|
previous_trailing = Hash.new('')
|
|
@@ -129,14 +241,12 @@ module CLI
|
|
|
129
241
|
break if ios.empty?
|
|
130
242
|
|
|
131
243
|
readers, = IO.select(ios)
|
|
132
|
-
readers.each do |io|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
io.close
|
|
139
|
-
end
|
|
244
|
+
(readers || []).each do |io|
|
|
245
|
+
data, trailing = split_partial_characters(io.readpartial(4096))
|
|
246
|
+
handlers[io].call(previous_trailing[io] + data)
|
|
247
|
+
previous_trailing[io] = trailing
|
|
248
|
+
rescue IOError
|
|
249
|
+
io.close
|
|
140
250
|
end
|
|
141
251
|
end
|
|
142
252
|
|
|
@@ -147,35 +257,94 @@ module CLI
|
|
|
147
257
|
# Split off trailing partial UTF-8 Characters. UTF-8 Multibyte characters start with a 11xxxxxx byte that tells
|
|
148
258
|
# how many following bytes are part of this character, followed by some number of 10xxxxxx bytes. This simple
|
|
149
259
|
# algorithm will split off a whole trailing multi-byte character.
|
|
260
|
+
sig { params(data: String).returns([String, String]) }
|
|
150
261
|
def split_partial_characters(data)
|
|
151
|
-
last_byte = data.getbyte(-1)
|
|
262
|
+
last_byte = T.must(data.getbyte(-1))
|
|
152
263
|
return [data, ''] if (last_byte & 0b1000_0000).zero?
|
|
153
264
|
|
|
154
|
-
# UTF-8 is up to
|
|
265
|
+
# UTF-8 is up to 4 characters per rune, so we could never want to trim more than that, and we want to avoid
|
|
155
266
|
# allocating an array for the whole of data with bytes
|
|
156
|
-
min_bound = -[
|
|
157
|
-
final_bytes = data.byteslice(min_bound..-1).bytes
|
|
267
|
+
min_bound = -[4, data.bytesize].min
|
|
268
|
+
final_bytes = T.must(data.byteslice(min_bound..-1)).bytes
|
|
158
269
|
partial_character_sub_index = final_bytes.rindex { |byte| byte & 0b1100_0000 == 0b1100_0000 }
|
|
270
|
+
|
|
159
271
|
# Bail out for non UTF-8
|
|
160
272
|
return [data, ''] unless partial_character_sub_index
|
|
273
|
+
|
|
274
|
+
start_byte = final_bytes[partial_character_sub_index]
|
|
275
|
+
full_size = if start_byte & 0b1111_1000 == 0b1111_0000
|
|
276
|
+
4
|
|
277
|
+
elsif start_byte & 0b1111_0000 == 0b1110_0000
|
|
278
|
+
3
|
|
279
|
+
elsif start_byte & 0b1110_0000 == 0b110_00000
|
|
280
|
+
2
|
|
281
|
+
else
|
|
282
|
+
nil # Not a valid UTF-8 character
|
|
283
|
+
end
|
|
284
|
+
return [data, ''] if full_size.nil? # Bail out for non UTF-8
|
|
285
|
+
|
|
286
|
+
if final_bytes.size - partial_character_sub_index == full_size
|
|
287
|
+
# We have a full UTF-8 character, so we can just return the data
|
|
288
|
+
return [data, '']
|
|
289
|
+
end
|
|
290
|
+
|
|
161
291
|
partial_character_index = min_bound + partial_character_sub_index
|
|
162
292
|
|
|
163
|
-
[data.byteslice(0...partial_character_index), data.byteslice(partial_character_index..-1)]
|
|
293
|
+
[T.must(data.byteslice(0...partial_character_index)), T.must(data.byteslice(partial_character_index..-1))]
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
sig { returns(Symbol) }
|
|
297
|
+
def os
|
|
298
|
+
return :mac if /darwin/.match(RUBY_PLATFORM)
|
|
299
|
+
return :linux if /linux/.match(RUBY_PLATFORM)
|
|
300
|
+
return :windows if /mingw32/.match(RUBY_PLATFORM)
|
|
301
|
+
|
|
302
|
+
raise "Could not determine OS from platform #{RUBY_PLATFORM}"
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
sig { params(cmd: String, env: T::Hash[String, T.nilable(String)]).returns(T.nilable(String)) }
|
|
306
|
+
def which(cmd, env)
|
|
307
|
+
exts = os == :windows ? (env['PATHEXT'] || 'exe').split(';') : ['']
|
|
308
|
+
(env['PATH'] || '').split(File::PATH_SEPARATOR).each do |path|
|
|
309
|
+
exts.each do |ext|
|
|
310
|
+
exe = File.join(path, "#{cmd}#{ext}")
|
|
311
|
+
return exe if File.executable?(exe) && !File.directory?(exe)
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
nil
|
|
164
316
|
end
|
|
165
317
|
|
|
166
318
|
private
|
|
167
319
|
|
|
168
|
-
|
|
169
|
-
|
|
320
|
+
sig do
|
|
321
|
+
params(cmd: String, args: T::Array[String], sudo: T.any(T::Boolean, String))
|
|
322
|
+
.returns([String, T::Array[String]])
|
|
323
|
+
end
|
|
324
|
+
def apply_sudo(cmd, args, sudo)
|
|
325
|
+
return [cmd, args] unless sudo
|
|
326
|
+
|
|
170
327
|
sudo_reason(sudo) if sudo.is_a?(String)
|
|
171
|
-
|
|
328
|
+
['sudo', args.unshift('-E', '-S', '-p', SUDO_PROMPT, '--', cmd)]
|
|
172
329
|
end
|
|
173
330
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
331
|
+
sig do
|
|
332
|
+
params(
|
|
333
|
+
cmd: String,
|
|
334
|
+
args: T::Array[String],
|
|
335
|
+
kwargs: T::Hash[Symbol, T.untyped],
|
|
336
|
+
sudo: T.any(T::Boolean, String),
|
|
337
|
+
env: T::Hash[String, T.nilable(String)],
|
|
338
|
+
method: Symbol,
|
|
339
|
+
block: T.untyped,
|
|
340
|
+
).returns(T.untyped)
|
|
341
|
+
end
|
|
342
|
+
def delegate_open3(cmd, args, kwargs, sudo: raise, env: raise, method: raise, &block)
|
|
343
|
+
cmd, args = apply_sudo(cmd, args, sudo)
|
|
344
|
+
cmd, args = resolve_path(cmd, args, env)
|
|
345
|
+
T.unsafe(Open3).send(method, env, cmd, *args, **kwargs, &block)
|
|
177
346
|
rescue Errno::EINTR
|
|
178
|
-
raise(Errno::EINTR, "command interrupted: #{
|
|
347
|
+
raise(Errno::EINTR, "command interrupted: #{cmd} #{args.join(" ")}")
|
|
179
348
|
end
|
|
180
349
|
|
|
181
350
|
# Ruby resolves the program to execute using its own PATH, but we want it to
|
|
@@ -187,19 +356,19 @@ module CLI
|
|
|
187
356
|
# project.
|
|
188
357
|
#
|
|
189
358
|
# See https://github.com/Shopify/dev/pull/625 for more details.
|
|
190
|
-
|
|
359
|
+
sig do
|
|
360
|
+
params(cmd: String, args: T::Array[String], env: T::Hash[String, T.nilable(String)])
|
|
361
|
+
.returns([String, T::Array[String]])
|
|
362
|
+
end
|
|
363
|
+
def resolve_path(cmd, args, env)
|
|
191
364
|
# If only one argument was provided, make sure it's interpreted by a shell.
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
paths = env.fetch('PATH', '').split(':')
|
|
196
|
-
item = paths.detect do |f|
|
|
197
|
-
command_path = "#{f}/#{a.first}"
|
|
198
|
-
File.executable?(command_path) && File.file?(command_path)
|
|
365
|
+
if args.empty?
|
|
366
|
+
prefix = os == :windows ? 'break && ' : 'true ; '
|
|
367
|
+
return [prefix + cmd, []]
|
|
199
368
|
end
|
|
369
|
+
return [cmd, args] if cmd.include?('/')
|
|
200
370
|
|
|
201
|
-
|
|
202
|
-
a
|
|
371
|
+
[which(cmd, env) || cmd, args]
|
|
203
372
|
end
|
|
204
373
|
end
|
|
205
374
|
end
|