Module: CLI::Kit::System

Defined in:
lib/cli/kit/system.rb,
lib/cli/kit/support/test_helper.rb

Constant Summary collapse

SUDO_PROMPT =
CLI::UI.fmt('{{info:(sudo)}} Password: ')

Class Method Summary collapse

Class Method Details

.capture2(cmd, *a, sudo: false, env: {}, **kwargs) ⇒ Object

Execute a command in the user’s environment This is meant to be largely equivalent to backticks, only with the env passed in. Captures the results of the command without output to the console

#### Parameters

  • ‘*a`: A splat of arguments evaluated as a command. (e.g. `’rm’, folder` is equivalent to ‘rm #folder`)

  • sudo: If truthy, run this command with sudo. If String, pass to sudo_reason

  • env: process environment with which to execute this command

  • ‘**kwargs`: additional arguments to pass to Open3.capture2

#### Returns

  • output: output (STDOUT) of the command execution

  • status: boolean success status of the command execution

#### Usage ‘out, stat = CLI::Kit::System.capture2(’ls’, ‘a_folder’)‘

: (String cmd, *String args, ?sudo: (String | bool), ?env: Hash[String, String?], **untyped kwargs) -> [String, Process::Status]



50
51
52
# File 'lib/cli/kit/system.rb', line 50

def capture2(cmd, *args, sudo: false, env: ENV.to_h, **kwargs)
  delegate_open3(cmd, args, kwargs, sudo: sudo, env: env, method: :capture2)
end

.capture2e(cmd, *a, sudo: false, env: {}, **kwargs) ⇒ Object

Execute a command in the user’s environment This is meant to be largely equivalent to backticks, only with the env passed in. Captures the results of the command without output to the console

#### Parameters

  • ‘*a`: A splat of arguments evaluated as a command. (e.g. `’rm’, folder` is equivalent to ‘rm #folder`)

  • sudo: If truthy, run this command with sudo. If String, pass to sudo_reason

  • env: process environment with which to execute this command

  • ‘**kwargs`: additional arguments to pass to Open3.capture2e

#### Returns

  • output: output (STDOUT merged with STDERR) of the command execution

  • status: boolean success status of the command execution

#### Usage ‘out_and_err, stat = CLI::Kit::System.capture2e(’ls’, ‘a_folder’)‘

: (String cmd, *String args, ?sudo: (String | bool), ?env: Hash[String, String?], **untyped kwargs) -> [String, Process::Status]



72
73
74
# File 'lib/cli/kit/system.rb', line 72

def capture2e(cmd, *args, sudo: false, env: ENV.to_h, **kwargs)
  delegate_open3(cmd, args, kwargs, sudo: sudo, env: env, method: :capture2e)
end

.capture3(cmd, *a, sudo: false, env: {}, **kwargs) ⇒ Object

Execute a command in the user’s environment This is meant to be largely equivalent to backticks, only with the env passed in. Captures the results of the command without output to the console

#### Parameters

  • ‘*a`: A splat of arguments evaluated as a command. (e.g. `’rm’, folder` is equivalent to ‘rm #folder`)

  • sudo: If truthy, run this command with sudo. If String, pass to sudo_reason

  • env: process environment with which to execute this command

  • ‘**kwargs`: additional arguments to pass to Open3.capture3

#### Returns

  • output: STDOUT of the command execution

  • error: STDERR of the command execution

  • status: boolean success status of the command execution

#### Usage ‘out, err, stat = CLI::Kit::System.capture3(’ls’, ‘a_folder’)‘

: (String cmd, *String args, ?sudo: (String | bool), ?env: Hash[String, String?], **untyped kwargs) -> [String, String, Process::Status]



95
96
97
# File 'lib/cli/kit/system.rb', line 95

def capture3(cmd, *args, sudo: false, env: ENV.to_h, **kwargs)
  delegate_open3(cmd, args, kwargs, sudo: sudo, env: env, method: :capture3)
end

.error_messageObject

Returns the errors associated to a test run

#### Returns errors (String) a string representing errors found on this run, nil if none



183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
# File 'lib/cli/kit/support/test_helper.rb', line 183

def error_message
  errors = {
    unexpected: [],
    not_run: [],
    other: {},
  }

  @delegate_open3.each do |cmd, opts|
    if opts[:unexpected]
      errors[:unexpected] << cmd
    elsif opts[:run]
      error = []

      if opts[:expected][:sudo] != opts[:actual][:sudo]
        error << "- sudo was supposed to be #{opts[:expected][:sudo]} but was #{opts[:actual][:sudo]}"
      end

      if opts[:expected][:env] != opts[:actual][:env]
        error << "- env was supposed to be #{opts[:expected][:env]} but was #{opts[:actual][:env]}"
      end

      errors[:other][cmd] = error.join("\n") unless error.empty?
    else
      errors[:not_run] << cmd
    end
  end

  final_error = []

  unless errors[:unexpected].empty?
    final_error << CLI::UI.fmt("      {{bold:Unexpected command invocations:}}\n      {{command:\#{errors[:unexpected].join(\"\\n\")}}}\n    EOF\n  end\n\n  unless errors[:not_run].empty?\n    final_error << CLI::UI.fmt(<<~EOF)\n      {{bold:Expected commands were not run:}}\n      {{command:\#{errors[:not_run].join(\"\\n\")}}}\n    EOF\n  end\n\n  unless errors[:other].empty?\n    final_error << CLI::UI.fmt(<<~EOF)\n      {{bold:Commands were not run as expected:}}\n      \#{errors[:other].map { |cmd, msg| \"{{command:\#{cmd}}}\\n\#{msg}\" }.join(\"\\n\\n\")}\n    EOF\n  end\n\n  return if final_error.empty?\n\n  \"\\n\" + final_error.join(\"\\n\") # Initial new line for formatting reasons\nend\n")

.fake(*a, stdout: '', stderr: '', allow: nil, success: nil, sudo: false, env: {}) ⇒ Object

Sets up an expectation for a command and stubs out the call (unless allow is true)

#### Parameters ‘*a` : the command, represented as a splat stdout : stdout to stub the command with (defaults to empty string) stderr : stderr to stub the command with (defaults to empty string) allow : allow determines if the command will be actually run, or stubbed. Defaults to nil (stub) success : success status to stub the command with (Defaults to nil) sudo : expectation of sudo being set or not (defaults to false) env : expectation of env being set or not (defaults to {})

Note: Must set allow or success

Raises:

  • (ArgumentError)


152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
# File 'lib/cli/kit/support/test_helper.rb', line 152

def fake(*a, stdout: '', stderr: '', allow: nil, success: nil, sudo: false, env: {})
  raise ArgumentError, 'success or allow must be set' if success.nil? && allow.nil?

  @delegate_open3 ||= {}
  @delegate_open3[a.join(' ')] = {
    expected: {
      sudo: sudo,
      env: env,
    },
    actual: {
      sudo: nil,
      env: nil,
    },
    stdout: stdout,
    stderr: stderr,
    allow: allow,
    success: success,
    run: false,
  }
end

.original_capture2Object

Execute a command in the user’s environment This is meant to be largely equivalent to backticks, only with the env passed in. Captures the results of the command without output to the console

#### Parameters

  • ‘*a`: A splat of arguments evaluated as a command. (e.g. `’rm’, folder` is equivalent to ‘rm #folder`)

  • sudo: If truthy, run this command with sudo. If String, pass to sudo_reason

  • env: process environment with which to execute this command

  • ‘**kwargs`: additional arguments to pass to Open3.capture2

#### Returns

  • output: output (STDOUT) of the command execution

  • status: boolean success status of the command execution

#### Usage ‘out, stat = CLI::Kit::System.capture2(’ls’, ‘a_folder’)‘

: (String cmd, *String args, ?sudo: (String | bool), ?env: Hash[String, String?], **untyped kwargs) -> [String, Process::Status]



78
79
80
# File 'lib/cli/kit/support/test_helper.rb', line 78

def capture2(cmd, *args, sudo: false, env: ENV.to_h, **kwargs)
  delegate_open3(cmd, args, kwargs, sudo: sudo, env: env, method: :capture2)
end

.original_capture2eObject

Execute a command in the user’s environment This is meant to be largely equivalent to backticks, only with the env passed in. Captures the results of the command without output to the console

#### Parameters

  • ‘*a`: A splat of arguments evaluated as a command. (e.g. `’rm’, folder` is equivalent to ‘rm #folder`)

  • sudo: If truthy, run this command with sudo. If String, pass to sudo_reason

  • env: process environment with which to execute this command

  • ‘**kwargs`: additional arguments to pass to Open3.capture2e

#### Returns

  • output: output (STDOUT merged with STDERR) of the command execution

  • status: boolean success status of the command execution

#### Usage ‘out_and_err, stat = CLI::Kit::System.capture2e(’ls’, ‘a_folder’)‘

: (String cmd, *String args, ?sudo: (String | bool), ?env: Hash[String, String?], **untyped kwargs) -> [String, Process::Status]



98
99
100
# File 'lib/cli/kit/support/test_helper.rb', line 98

def capture2e(cmd, *args, sudo: false, env: ENV.to_h, **kwargs)
  delegate_open3(cmd, args, kwargs, sudo: sudo, env: env, method: :capture2e)
end

.original_capture3Object

Execute a command in the user’s environment This is meant to be largely equivalent to backticks, only with the env passed in. Captures the results of the command without output to the console

#### Parameters

  • ‘*a`: A splat of arguments evaluated as a command. (e.g. `’rm’, folder` is equivalent to ‘rm #folder`)

  • sudo: If truthy, run this command with sudo. If String, pass to sudo_reason

  • env: process environment with which to execute this command

  • ‘**kwargs`: additional arguments to pass to Open3.capture3

#### Returns

  • output: STDOUT of the command execution

  • error: STDERR of the command execution

  • status: boolean success status of the command execution

#### Usage ‘out, err, stat = CLI::Kit::System.capture3(’ls’, ‘a_folder’)‘

: (String cmd, *String args, ?sudo: (String | bool), ?env: Hash[String, String?], **untyped kwargs) -> [String, String, Process::Status]



118
119
120
# File 'lib/cli/kit/support/test_helper.rb', line 118

def capture3(cmd, *args, sudo: false, env: ENV.to_h, **kwargs)
  delegate_open3(cmd, args, kwargs, sudo: sudo, env: env, method: :capture3)
end

.original_systemObject

Execute a command in the user’s environment Outputs result of the command without capturing it

#### Parameters

  • ‘*a`: A splat of arguments evaluated as a command. (e.g. `’rm’, folder` is equivalent to ‘rm #folder`)

  • sudo: If truthy, run this command with sudo. If String, pass to sudo_reason

  • env: process environment with which to execute this command

  • ‘**kwargs`: additional keyword arguments to pass to Process.spawn

#### Returns

  • status: The Process:Status result for the command execution

#### Usage ‘stat = CLI::Kit::System.system(’ls’, ‘a_folder’)‘

: (String cmd, *String args, ?sudo: (String | bool), ?env: Hash[String, String?], ?stdin: (IO | String | Integer | Symbol)?, **untyped kwargs) ?{ (String out, String err) -> void } -> Process::Status



61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
# File 'lib/cli/kit/support/test_helper.rb', line 61

def system(cmd, *args, sudo: false, env: ENV.to_h, stdin: nil, **kwargs, &block)
  cmd, args = apply_sudo(cmd, args, sudo)

  out_r, out_w = IO.pipe
  err_r, err_w = IO.pipe
  in_stream = if stdin
    stdin
  elsif STDIN.closed?
    :close
  else
    STDIN
  end
  cmd, args = resolve_path(cmd, args, env)
  process = Process #: as untyped
  pid = process.spawn(env, cmd, *args, 0 => in_stream, :out => out_w, :err => err_w, **kwargs)
  out_w.close
  err_w.close

  handlers = if block_given?
    {
      out_r => ->(data) { yield(data.force_encoding(Encoding::UTF_8), '') },
      err_r => ->(data) { yield('', data.force_encoding(Encoding::UTF_8)) },
    }
  else
    {
      out_r => ->(data) { STDOUT.write(data) },
      err_r => ->(data) { STDOUT.write(data) },
    }
  end

  previous_trailing = Hash.new('')
  loop do
    break if Process.wait(pid, Process::WNOHANG)

    ios = [err_r, out_r].reject(&:closed?)
    next if ios.empty?

    readers, = IO.select(ios, [], [], 1)
    next if readers.nil? # If IO.select times out we iterate again so we can check if the process has exited

    readers.each do |io|
      data, trailing = split_partial_characters(io.readpartial(4096))
      handlers[io].call(previous_trailing[io] + data)
      previous_trailing[io] = trailing
    rescue IOError
      io.close
    end
  end

  $CHILD_STATUS
end

.osObject

: -> Symbol



226
227
228
229
230
231
232
# File 'lib/cli/kit/system.rb', line 226

def os
  return :mac if /darwin/.match(RUBY_PLATFORM)
  return :linux if /linux/.match(RUBY_PLATFORM)
  return :windows if /mingw/.match(RUBY_PLATFORM)

  raise "Could not determine OS from platform #{RUBY_PLATFORM}"
end

.popen2(cmd, *args, sudo: false, env: ENV.to_h, **kwargs, &block) ⇒ Object

: (String cmd, *String args, ?sudo: (String | bool), ?env: Hash[String, String?], **untyped kwargs) ?{ (IO stdin, IO stdout, Process::Waiter wait_thr) -> [IO, IO, Process::Waiter] } -> [IO, IO, Process::Waiter]



100
101
102
# File 'lib/cli/kit/system.rb', line 100

def popen2(cmd, *args, sudo: false, env: ENV.to_h, **kwargs, &block)
  delegate_open3(cmd, args, kwargs, sudo: sudo, env: env, method: :popen2, &block)
end

.popen2e(cmd, *args, sudo: false, env: ENV.to_h, **kwargs, &block) ⇒ Object

: (String cmd, *String args, ?sudo: (String | bool), ?env: Hash[String, String?], **untyped kwargs) ?{ (IO stdin, IO stdout, Process::Waiter wait_thr) -> [IO, IO, Process::Waiter] } -> [IO, IO, Process::Waiter]



105
106
107
# File 'lib/cli/kit/system.rb', line 105

def popen2e(cmd, *args, sudo: false, env: ENV.to_h, **kwargs, &block)
  delegate_open3(cmd, args, kwargs, sudo: sudo, env: env, method: :popen2e, &block)
end

.popen3(cmd, *args, sudo: false, env: ENV.to_h, **kwargs, &block) ⇒ Object

: (String cmd, *String args, ?sudo: (String | bool), ?env: Hash[String, String?], **untyped kwargs) ?{ (IO stdin, IO stdout, IO stderr, Process::Waiter wait_thr) -> [IO, IO, IO, Process::Waiter] } -> [IO, IO, IO, Process::Waiter]



110
111
112
# File 'lib/cli/kit/system.rb', line 110

def popen3(cmd, *args, sudo: false, env: ENV.to_h, **kwargs, &block)
  delegate_open3(cmd, args, kwargs, sudo: sudo, env: env, method: :popen3, &block)
end

.reset!Object

Resets the faked commands



175
176
177
# File 'lib/cli/kit/support/test_helper.rb', line 175

def reset!
  @delegate_open3 = {}
end

.split_partial_characters(data) ⇒ Object

Split off trailing partial UTF-8 Characters. UTF-8 Multibyte characters start with a 11xxxxxx byte that tells how many following bytes are part of this character, followed by some number of 10xxxxxx bytes. This simple algorithm will split off a whole trailing multi-byte character. : (String data) -> [String, String]



186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
# File 'lib/cli/kit/system.rb', line 186

def split_partial_characters(data)
  last_byte = data.getbyte(-1) #: as !nil
  return [data, ''] if (last_byte & 0b1000_0000).zero?

  # UTF-8 is up to 4 characters per rune, so we could never want to trim more than that, and we want to avoid
  # allocating an array for the whole of data with bytes
  min_bound = -[4, data.bytesize].min
  fb = data.byteslice(min_bound..-1) #: as !nil
  final_bytes = fb.bytes
  partial_character_sub_index = final_bytes.rindex { |byte| byte & 0b1100_0000 == 0b1100_0000 }

  # Bail out for non UTF-8
  return [data, ''] unless partial_character_sub_index

  start_byte = final_bytes[partial_character_sub_index]
  full_size = if start_byte & 0b1111_1000 == 0b1111_0000
    4
  elsif start_byte & 0b1111_0000 == 0b1110_0000
    3
  elsif start_byte & 0b1110_0000 == 0b110_00000
    2
  else
    nil # Not a valid UTF-8 character
  end
  return [data, ''] if full_size.nil? # Bail out for non UTF-8

  if final_bytes.size - partial_character_sub_index == full_size
    # We have a full UTF-8 character, so we can just return the data
    return [data, '']
  end

  partial_character_index = min_bound + partial_character_sub_index

  [
    data.byteslice(0...partial_character_index), #: as !nil
    data.byteslice(partial_character_index..-1), #: as !nil
  ]
end

.sudo_reason(msg) ⇒ Object

Ask for sudo access with a message explaning the need for it Will make subsequent commands capable of running with sudo for a period of time

#### Parameters

  • msg: A message telling the user why sudo is needed

#### Usage ‘ctx.sudo_reason(“We need to do a thing”)`

: (String msg) -> void



22
23
24
25
26
27
28
29
30
# File 'lib/cli/kit/system.rb', line 22

def sudo_reason(msg)
  # See if sudo has a cached password
  %x(env SUDO_ASKPASS=/usr/bin/false sudo -A true > /dev/null 2>&1)
  return if $CHILD_STATUS.success?

  CLI::UI.with_frame_color(:blue) do
    puts(CLI::UI.fmt("{{i}} #{msg}"))
  end
end

.system(cmd, *a, sudo: false, env: {}, stdin: nil, **kwargs) ⇒ Object

Execute a command in the user’s environment Outputs result of the command without capturing it

#### Parameters

  • ‘*a`: A splat of arguments evaluated as a command. (e.g. `’rm’, folder` is equivalent to ‘rm #folder`)

  • sudo: If truthy, run this command with sudo. If String, pass to sudo_reason

  • env: process environment with which to execute this command

  • ‘**kwargs`: additional keyword arguments to pass to Process.spawn

#### Returns

  • status: The Process:Status result for the command execution

#### Usage ‘stat = CLI::Kit::System.system(’ls’, ‘a_folder’)‘

: (String cmd, *String args, ?sudo: (String | bool), ?env: Hash[String, String?], ?stdin: (IO | String | Integer | Symbol)?, **untyped kwargs) ?{ (String out, String err) -> void } -> Process::Status



130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
# File 'lib/cli/kit/system.rb', line 130

def system(cmd, *args, sudo: false, env: ENV.to_h, stdin: nil, **kwargs, &block)
  cmd, args = apply_sudo(cmd, args, sudo)

  out_r, out_w = IO.pipe
  err_r, err_w = IO.pipe
  in_stream = if stdin
    stdin
  elsif STDIN.closed?
    :close
  else
    STDIN
  end
  cmd, args = resolve_path(cmd, args, env)
  process = Process #: as untyped
  pid = process.spawn(env, cmd, *args, 0 => in_stream, :out => out_w, :err => err_w, **kwargs)
  out_w.close
  err_w.close

  handlers = if block_given?
    {
      out_r => ->(data) { yield(data.force_encoding(Encoding::UTF_8), '') },
      err_r => ->(data) { yield('', data.force_encoding(Encoding::UTF_8)) },
    }
  else
    {
      out_r => ->(data) { STDOUT.write(data) },
      err_r => ->(data) { STDOUT.write(data) },
    }
  end

  previous_trailing = Hash.new('')
  loop do
    break if Process.wait(pid, Process::WNOHANG)

    ios = [err_r, out_r].reject(&:closed?)
    next if ios.empty?

    readers, = IO.select(ios, [], [], 1)
    next if readers.nil? # If IO.select times out we iterate again so we can check if the process has exited

    readers.each do |io|
      data, trailing = split_partial_characters(io.readpartial(4096))
      handlers[io].call(previous_trailing[io] + data)
      previous_trailing[io] = trailing
    rescue IOError
      io.close
    end
  end

  $CHILD_STATUS
end

.which(cmd, env) ⇒ Object

: (String cmd, Hash[String, String?] env) -> String?



235
236
237
238
239
240
241
242
243
244
245
# File 'lib/cli/kit/system.rb', line 235

def which(cmd, env)
  exts = os == :windows ? (env['PATHEXT'] || 'exe').split(';') : ['']
  (env['PATH'] || '').split(File::PATH_SEPARATOR).each do |path|
    exts.each do |ext|
      exe = File.join(path, "#{cmd}#{ext}")
      return exe if File.executable?(exe) && !File.directory?(exe)
    end
  end

  nil
end