lib/makit/command_runner.rb in makit-0.0.35 vs lib/makit/command_runner.rb in makit-0.0.36

- old
+ new

@@ -1,353 +1,391 @@ -require "English" -require "open3" -require "socket" -require "etc" -require "logger" -require_relative "mp/command_request.mp" - -# This module provides classes for the Makit gem. -module Makit - # This class provide methods running commands. - # - class CommandRunner - attr_accessor :show_output_on_success, :log_to_artifacts, :commands - attr_accessor :debug, :default_modified - - def initialize - @show_output_on_success = false - @log_to_artifacts = false - @commands = [] - @debug = false - @default_modified = Makit::Git::get_file_infos.first.mtime # Makit::GIT_FILE_INFOS.first.mtime - end - - def get_cache_filename(command) - int_hash = Digest::SHA256.hexdigest("#{command.name}.#{command.arguments.join(" ")}") - hash_string = "#{int_hash}" - cache_filename = Makit::Directories::PROJECT_ARTIFACTS + - "/commands/cache/#{hash_string}.pb" - # create the directory if it does not already exist - FileUtils.mkdir_p(File.dirname(cache_filename)) - cache_filename - end - - def cache_run(command_request) - cache_run(command_request, @default_modified) - end - - # if there is a matching cached command result, that then the specified timestamp, - # then return the cached result - # otherwise run the command and save the result to a cache file - # then return the result - def cache_run(command_request, timestamp) - raise "Invalid command_request" unless command_request.is_a? Makit::V1::CommandRequest - cache_filename = get_cache_filename(command_request) - - # Check if timestamp is a valid Time object - unless timestamp.is_a?(Time) - raise ArgumentError, "timestamp must be a Time object, got #{timestamp.class}" - end - - if debug - puts " timestamp: #{Makit::Humanize.get_humanized_timestamp(timestamp)}" - puts " command.name: #{command_request.name}" - puts " command.arguments: #{command_request.arguments.join(" ")}" - puts " cache_filename: #{cache_filename}" - end - #cache_filename = Makit::Directories::PROJECT_ARTIFACTS + "/commands/#{command_request.name}.#{command_request.arguments.join("_")}.#{timestamp.seconds}.pb" - if File.exist?(cache_filename) - cache_timestamp = File.mtime(cache_filename) - if debug - puts " cache timestamp: #{Makit::Humanize.get_humanized_timestamp(cache_timestamp)}" - end - #puts "cache file date: #{File.mtime(cache_filename)}" - if (File.mtime(cache_filename) > timestamp) - - #puts " found cached command (newer than #{timestamp})".colorize(:grey) - #puts " cache_filename: #{cache_filename}" - command = Makit::Serializer.open(cache_filename, Makit::V1::Command) - show_cached_command(command) - if command.exit_code != 0 - abort "cached command failed: #{command.name} #{command.arguments.join(" ")}" - end - return command - else - puts " cache_filename exists, but is older than #{timestamp}" if debug - File.delete(cache_filename) - end - end - - command = run(command_request) - # make sure the cache directory exists - FileUtils.mkdir_p(File.dirname(cache_filename)) - #puts "saving command to cache_filename" - Makit::Serializer.save_as(cache_filename, command) - commands.push(command) - command - end - - def show_command(command) - if command.exit_code != 0 - puts Makit::CommandRunner.get_command_summary(command) + " (exit code #{command.exit_code})".colorize(:default) - puts " directory: #{command.directory}\n" - puts " duration: #{command.duration.seconds} seconds\n" - puts Makit::Humanize::indent_string(command.output, 2) if command.output.length > 0 - puts Makit::Humanize::indent_string(command.error, 2) if command.error.length > 0 - #exit 1 if command.exit_on_error - else - puts Makit::CommandRunner.get_command_summary(command) + " (#{command.duration.seconds} seconds)".colorize(:cyan) - puts Makit::Humanize::indent_string(command.output, 2).colorize(:default) if show_output_on_success - end - end - - def show_cached_command(command) - if command.exit_code != 0 - puts Makit::CommandRunner.get_command_summary(command) + " (exit code #{command.exit_code})".colorize(:default) - puts " directory: #{command.directory}\n" - puts " duration: #{command.duration.seconds} seconds\n" - puts Makit::Humanize::indent_string(command.output, 2) if command.output.length > 0 - puts Makit::Humanize::indent_string(command.error, 2) if command.error.length > 0 - #exit 1 if command.exit_on_error - else - puts Makit::CommandRunner.get_command_summary(command).strip_color_codes.colorize(:grey) + " (#{command.duration.seconds} seconds)".colorize(:grey) - puts Makit::Humanize::indent_string(command.output, 2).colorize(:default) if show_output_on_success - end - end - - # Run a command and return a Makit::V1::Command. - def run(command_request) - raise "Invalid command_request" unless command_request.is_a? Makit::V1::CommandRequest - command = execute(command_request) - show_output = true - exit_on_error = true - - log_to_artifacts(command) if @log_to_artifacts - show_command(command) - if command.exit_code != 0 - exit 1 if command_request.exit_on_error - end - commands.push(command) - command - end - - def log_to_artifacts(command) - log_filename = get_cache_filename(command) - Makit::Serializer.save_as(log_filename, command) - end - - # Run a command and return a Makit::V1::Command. - def try(args) - request = parse_command_request(args) - request.exit_on_error = false - run(request) - #run2(args, false) - end - - # Show the output of a command and return a Makit::V1::Command. - def show(args) - request = parse_args(args) - command = execute(request) - - show_output = true - exit_on_error = true - Makit::LOGGER.info(Makit::CommandRunner.get_command_summary(command)) - show_output = true if command.exit_code != 0 - Makit::LOGGER.info(indent_string("\n#{command.output}\n#{command.error}\n".strip, 2)) if show_output - exit(command.exit_code) if exit_on_error && command.exit_code != 0 # unless process_status.success? - command - end - - # Parse and return a Makit::V1::CommandRequest. - def parse_command_request(source) - return Makit::V1::CommandRequest.new(source) if source.is_a? Hash - return source if source.is_a? Makit::V1::CommandRequest - if source.is_a? String - return parse_args(source) - end - - raise "Invalid source" unless source.is_a? Makit::V1::CommandRequest - end - - def parse_command_request_from_hash(hash) - raise "Invalid hash" unless hash.is_a? Hash - Makit::V1::CommandRequest.new(hash) - end - - def parse_command_request_from_string(source) - raise "Invalid source" unless source.is_a? String - words = source.split(" ") - hash = { - name: words.shift, - arguments: words, - exit_on_error: true, - } - end - - # Parse the command line arguments into a Makit::V1::CommandRequest. - def parse_args(args) - #raise "No command specified" if args.empty? - if args.is_a? Makit::V1::CommandRequest - args - else - if args.is_a? String - args = args.split(" ") - if (args.length == 1) - hash = { - name: args[0], - arguments: [], - exit_on_error: true, - } - Makit::V1::CommandRequest.new(hash) - else - hash = { - name: args.shift, - arguments: args, - exit_on_error: true, - } - - Makit::V1::CommandRequest.new(hash) - end - else - Makit::V1::CommandRequest.new(args) - end - end - end - - def get_path_name(name) - # replace all characters that a not valid in a filename with an underscore - name.gsub(/[^0-9a-z]/i, "_") - end - - # Given a Makit::V1::CommandRequest, execute the command and return a Makit::V1::Command. - def execute(args) - command_request = parse_args(args) - command_request.directory = Dir.pwd if command_request.directory.nil? - command_request.directory = Dir.pwd if command_request.directory.length == 0 - result = Makit::V1::Command.new(name: command_request.name) - command_request.arguments.each do |arg| - result.arguments.push(arg) - end - command = "#{command_request.name} #{command_request.arguments.join(" ")}" - result.directory = command_request.directory - start = Time.now - filename_friendly_timestamp = Time.now.strftime("%Y.%m.%d_%H%M%S") - log_filename = File.join(Makit::Directories::LOG, "#{filename_friendly_timestamp}.log") - - # assign a directory variable to the current working directory, if not specified, - # otherwise assign the specified directory - command_request.directory = Dir.pwd if command_request.directory.nil? - command_request.directory = Dir.pwd if command_request.directory.length == 0 - raise "Invalid directory" unless Dir.exist?(command_request.directory) - - result.started_at = Google::Protobuf::Timestamp.new(seconds: start.to_i, nanos: start.nsec.to_i) - ############# execute the command - (output, error, exit_code) = execute_command(command, command_request.directory, command_request.timeout) - result.output = output.force_encoding("ASCII-8BIT") - result.error = error.force_encoding("ASCII-8BIT") - result.exit_code = exit_code.nil? ? 0 : exit_code - - elapsed_time = Time.now - start - seconds = elapsed_time.to_i - nanos = ((elapsed_time - seconds) * 1_000_000_000).to_i - - result.duration = Google::Protobuf::Duration.new(seconds: seconds, nanos: nanos) - - result - end - - # pure function to execute a command - # returns (stdout, stderr, exit_code) or raise an exception - def execute_command(command, directory, timeout) - original_directory = Dir.pwd - begin - output = nil - error = nil - process_status = nil - Dir.chdir(directory) do - output, error, process_status = Open3.capture3(command) - end - return [output, error, process_status.exitstatus] - rescue => e - # restore the original working directory - Dir.chdir(original_directory) - message_parts = [] - message_parts << "failed to execute #{command}" - message_parts << "directory: #{directory}" - message_parts << "timeout: #{timeout}" unless timeout.nil? - message_parts << "error: #{e.message}" - message = message_parts.join("\n") + "\n" - return ["", message, 1] - #raise Makit::Error, message - end - end - - def execute_command_request(command_request) - # if the command_request is not a Makit::V1::CommandRequest, raise an error - raise "Invalid command_request" unless command_request.is_a? Makit::V1::CommandRequest - - args = Array.new - command_request.arguments.each do |arg| - args.push(arg) - end - result = Makit::V1::Command.new({ - name: command_request.name, - arguments: args, - started_at: Google::Protobuf::Timestamp.new({ seconds: Time.now.to_i, nanos: Time.now.nsec }), - }) - - begin - rescue => e - end - end - - def indent_string(input_string, indent_spaces) - indentation = " " * indent_spaces - input_string.lines.map { |line| indentation + line }.join - end - - def self.get_command_summary(command) - symbol = Makit::Symbols.warning - symbol = Makit::Symbols.checkmark if !command.exit_code.nil? && command.exit_code.zero? - symbol = Makit::Symbols.error if command.exit_code != 0 - summary = "#{symbol} #{command.name.colorize(:yellow)} #{command.arguments.join(" ")}" - - if summary.length > 80 - summary = summary.to_lines(80, command.name.length + 3) - end - - summary - end - - def log_rake_commands - dir = File.join(Makit::Directories::PROJECT_ARTIFACTS, "commands") - FileUtils.mkdir_p(dir) unless Dir.exist?(dir) - - # open a text file to write to - File.open(File.join(dir, "rake.commands.txt"), "w") do |file| - #rake_commands = commands.select { |command| command.name == "rake" } - commands.each do |command| - file.puts " " + Makit::CommandRunner.get_command_summary(command).strip_color_codes - file.puts " start time: #{command.started_at}" - file.puts command.output.strip_color_codes unless command.output.strip_color_codes.strip.length == 0 - file.puts command.error.strip_color_codes unless command.error.strip_color_codes.strip.length == 0 - file.puts " " - end - end - end - - def log_slowest_commands - dir = File.join(Makit::Directories::PROJECT_ARTIFACTS, "commands") - FileUtils.mkdir_p(dir) unless Dir.exist?(dir) - - # open a text file to write to - File.open(File.join(dir, "slow.commands.txt"), "w") do |file| - Makit::RUNNER.commands.sort_by { |command| (command.duration.seconds + (command.duration.nanos / 1_000_000_000.0)) }.reverse.first(5).each do |command| - # Convert to float representation - duration_in_float = command.duration.seconds + (command.duration.nanos / 1_000_000_000.0) - #puts " #{command.name} took #{duration_in_float} seconds" - file.puts " " + Makit::CommandRunner.get_command_summary(command).strip_color_codes + " (#{command.duration.seconds} seconds)" - end - end - end - end # class CommandRunner -end # module Makit +require "English" +require "open3" +require "socket" +require "etc" +require "logger" +require_relative "mp/command_request.mp" + +# This module provides classes for the Makit gem. +module Makit + # This class provide methods running commands. + # + class CommandRunner + attr_accessor :show_output_on_success, :log_to_artifacts, :commands + attr_accessor :debug, :default_modified + + def initialize + @show_output_on_success = false + @log_to_artifacts = false + @commands = [] + @debug = false + @default_modified = Makit::Git::get_file_infos.first.mtime # Makit::GIT_FILE_INFOS.first.mtime + end + + # search command history for matching commands + def search(query) + results = [] + commands.each do |command| + keywords = [] + keywords << command.name.downcase + command.arguments.each { |argument| + keywords << argument.downcase unless keywords.include?(argument.downcase) + } + # output is bytes, so convert to UTF 8 string + #output_bytes = command.output.clone + #output = command.output.clone.force_encoding('UTF-8') + #error_bytes = command.error.clone + #error = error_bytes.force_encoding('UTF-8') + #output.split(" ").each {|word| + # if(word.length > 3) + # keywords << word.downcase unless keywords.include?(word.downcase) + # end + #} + #error.split(" ").each { |word| + # if(word.length > 3) + # keywords << word.downcase unless keywords.include?(word.downcase) + # end + #} + matchCount = 0 + terms = query.downcase.split(" ") + terms.each { |term| + if (keywords.include?(term)) + matchCount += 1 + end + } + if (matchCount == terms.length) + results << command + end + end + results + end + + def get_cache_filename(command) + int_hash = Digest::SHA256.hexdigest("#{command.name}.#{command.arguments.join(" ")}") + hash_string = "#{int_hash}" + cache_filename = Makit::Directories::PROJECT_ARTIFACTS + + "/commands/cache/#{hash_string}.pb" + # create the directory if it does not already exist + FileUtils.mkdir_p(File.dirname(cache_filename)) + cache_filename + end + + def cache_run(command_request) + cache_run(command_request, @default_modified) + end + + # if there is a matching cached command result, that then the specified timestamp, + # then return the cached result + # otherwise run the command and save the result to a cache file + # then return the result + def cache_run(command_request, timestamp) + raise "Invalid command_request" unless command_request.is_a? Makit::V1::CommandRequest + cache_filename = get_cache_filename(command_request) + + # Check if timestamp is a valid Time object + unless timestamp.is_a?(Time) + raise ArgumentError, "timestamp must be a Time object, got #{timestamp.class}" + end + + if debug + puts " timestamp: #{Makit::Humanize.get_humanized_timestamp(timestamp)}" + puts " command.name: #{command_request.name}" + puts " command.arguments: #{command_request.arguments.join(" ")}" + puts " cache_filename: #{cache_filename}" + end + #cache_filename = Makit::Directories::PROJECT_ARTIFACTS + "/commands/#{command_request.name}.#{command_request.arguments.join("_")}.#{timestamp.seconds}.pb" + if File.exist?(cache_filename) + cache_timestamp = File.mtime(cache_filename) + if debug + puts " cache timestamp: #{Makit::Humanize.get_humanized_timestamp(cache_timestamp)}" + end + #puts "cache file date: #{File.mtime(cache_filename)}" + if (File.mtime(cache_filename) > timestamp) + + #puts " found cached command (newer than #{timestamp})".colorize(:grey) + #puts " cache_filename: #{cache_filename}" + command = Makit::Serializer.open(cache_filename, Makit::V1::Command) + show_cached_command(command) + if command.exit_code != 0 + abort "cached command failed: #{command.name} #{command.arguments.join(" ")}" + end + return command + else + puts " cache_filename exists, but is older than #{timestamp}" if debug + File.delete(cache_filename) + end + end + + command = run(command_request) + # make sure the cache directory exists + FileUtils.mkdir_p(File.dirname(cache_filename)) + #puts "saving command to cache_filename" + Makit::Serializer.save_as(cache_filename, command) + commands.push(command) + command + end + + def show_command(command) + if command.exit_code != 0 + puts Makit::CommandRunner.get_command_summary(command) + " (exit code #{command.exit_code})".colorize(:default) + puts " directory: #{command.directory}\n" + puts " duration: #{command.duration.seconds} seconds\n" + puts Makit::Humanize::indent_string(command.output, 2) if command.output.length > 0 + puts Makit::Humanize::indent_string(command.error, 2) if command.error.length > 0 + #exit 1 if command.exit_on_error + else + puts Makit::CommandRunner.get_command_summary(command) + " (#{command.duration.seconds} seconds)".colorize(:cyan) + puts Makit::Humanize::indent_string(command.output, 2).colorize(:default) if show_output_on_success + end + end + + def show_cached_command(command) + if command.exit_code != 0 + puts Makit::CommandRunner.get_command_summary(command) + " (exit code #{command.exit_code})".colorize(:default) + puts " directory: #{command.directory}\n" + puts " duration: #{command.duration.seconds} seconds\n" + puts Makit::Humanize::indent_string(command.output, 2) if command.output.length > 0 + puts Makit::Humanize::indent_string(command.error, 2) if command.error.length > 0 + #exit 1 if command.exit_on_error + else + puts Makit::CommandRunner.get_command_summary(command).strip_color_codes.colorize(:grey) + " (#{command.duration.seconds} seconds)".colorize(:grey) + puts Makit::Humanize::indent_string(command.output, 2).colorize(:default) if show_output_on_success + end + end + + # Run a command and return a Makit::V1::Command. + def run(command_request) + raise "Invalid command_request" unless command_request.is_a? Makit::V1::CommandRequest + command = execute(command_request) + show_output = true + exit_on_error = true + + log_to_artifacts(command) if @log_to_artifacts + show_command(command) + if command.exit_code != 0 + exit 1 if command_request.exit_on_error + end + commands.push(command) + command + end + + def log_to_artifacts(command) + log_filename = get_cache_filename(command) + Makit::Serializer.save_as(log_filename, command) + end + + # Run a command and return a Makit::V1::Command. + def try(args) + request = parse_command_request(args) + request.exit_on_error = false + run(request) + #run2(args, false) + end + + # Show the output of a command and return a Makit::V1::Command. + def show(args) + request = parse_args(args) + command = execute(request) + + show_output = true + exit_on_error = true + Makit::LOGGER.info(Makit::CommandRunner.get_command_summary(command)) + show_output = true if command.exit_code != 0 + Makit::LOGGER.info(indent_string("\n#{command.output}\n#{command.error}\n".strip, 2)) if show_output + exit(command.exit_code) if exit_on_error && command.exit_code != 0 # unless process_status.success? + command + end + + # Parse and return a Makit::V1::CommandRequest. + def parse_command_request(source) + return Makit::V1::CommandRequest.new(source) if source.is_a? Hash + return source if source.is_a? Makit::V1::CommandRequest + if source.is_a? String + return parse_args(source) + end + + raise "Invalid source" unless source.is_a? Makit::V1::CommandRequest + end + + def parse_command_request_from_hash(hash) + raise "Invalid hash" unless hash.is_a? Hash + Makit::V1::CommandRequest.new(hash) + end + + def parse_command_request_from_string(source) + raise "Invalid source" unless source.is_a? String + words = source.split(" ") + hash = { + name: words.shift, + arguments: words, + exit_on_error: true, + } + end + + # Parse the command line arguments into a Makit::V1::CommandRequest. + def parse_args(args) + #raise "No command specified" if args.empty? + if args.is_a? Makit::V1::CommandRequest + args + else + if args.is_a? String + args = args.split(" ") + if (args.length == 1) + hash = { + name: args[0], + arguments: [], + exit_on_error: true, + } + Makit::V1::CommandRequest.new(hash) + else + hash = { + name: args.shift, + arguments: args, + exit_on_error: true, + } + + Makit::V1::CommandRequest.new(hash) + end + else + Makit::V1::CommandRequest.new(args) + end + end + end + + def get_path_name(name) + # replace all characters that a not valid in a filename with an underscore + name.gsub(/[^0-9a-z]/i, "_") + end + + # Given a Makit::V1::CommandRequest, execute the command and return a Makit::V1::Command. + def execute(args) + command_request = parse_args(args) + command_request.directory = Dir.pwd if command_request.directory.nil? + command_request.directory = Dir.pwd if command_request.directory.length == 0 + result = Makit::V1::Command.new(name: command_request.name) + command_request.arguments.each do |arg| + result.arguments.push(arg) + end + command = "#{command_request.name} #{command_request.arguments.join(" ")}" + result.directory = command_request.directory + start = Time.now + filename_friendly_timestamp = Time.now.strftime("%Y.%m.%d_%H%M%S") + log_filename = File.join(Makit::Directories::LOG, "#{filename_friendly_timestamp}.log") + + # assign a directory variable to the current working directory, if not specified, + # otherwise assign the specified directory + command_request.directory = Dir.pwd if command_request.directory.nil? + command_request.directory = Dir.pwd if command_request.directory.length == 0 + raise "Invalid directory" unless Dir.exist?(command_request.directory) + + result.started_at = Google::Protobuf::Timestamp.new(seconds: start.to_i, nanos: start.nsec.to_i) + ############# execute the command + (output, error, exit_code) = execute_command(command, command_request.directory, command_request.timeout) + result.output = output.force_encoding("ASCII-8BIT") + result.error = error.force_encoding("ASCII-8BIT") + result.exit_code = exit_code.nil? ? 0 : exit_code + + elapsed_time = Time.now - start + seconds = elapsed_time.to_i + nanos = ((elapsed_time - seconds) * 1_000_000_000).to_i + + result.duration = Google::Protobuf::Duration.new(seconds: seconds, nanos: nanos) + + result + end + + # pure function to execute a command + # returns (stdout, stderr, exit_code) or raise an exception + def execute_command(command, directory, timeout) + original_directory = Dir.pwd + begin + output = nil + error = nil + process_status = nil + Dir.chdir(directory) do + output, error, process_status = Open3.capture3(command) + end + return [output, error, process_status.exitstatus] + rescue => e + # restore the original working directory + Dir.chdir(original_directory) + message_parts = [] + message_parts << "failed to execute #{command}" + message_parts << "directory: #{directory}" + message_parts << "timeout: #{timeout}" unless timeout.nil? + message_parts << "error: #{e.message}" + message = message_parts.join("\n") + "\n" + return ["", message, 1] + #raise Makit::Error, message + end + end + + def execute_command_request(command_request) + # if the command_request is not a Makit::V1::CommandRequest, raise an error + raise "Invalid command_request" unless command_request.is_a? Makit::V1::CommandRequest + + args = Array.new + command_request.arguments.each do |arg| + args.push(arg) + end + result = Makit::V1::Command.new({ + name: command_request.name, + arguments: args, + started_at: Google::Protobuf::Timestamp.new({ seconds: Time.now.to_i, nanos: Time.now.nsec }), + }) + + begin + rescue => e + end + end + + def indent_string(input_string, indent_spaces) + indentation = " " * indent_spaces + input_string.lines.map { |line| indentation + line }.join + end + + def self.get_command_summary(command) + symbol = Makit::Symbols.warning + symbol = Makit::Symbols.checkmark if !command.exit_code.nil? && command.exit_code.zero? + symbol = Makit::Symbols.error if command.exit_code != 0 + summary = "#{symbol} #{command.name.colorize(:yellow)} #{command.arguments.join(" ")}" + + if summary.length > 80 + summary = summary.to_lines(80, command.name.length + 3) + end + + summary + end + + def log_rake_commands + dir = File.join(Makit::Directories::PROJECT_ARTIFACTS, "commands") + FileUtils.mkdir_p(dir) unless Dir.exist?(dir) + + # open a text file to write to + File.open(File.join(dir, "rake.commands.txt"), "w") do |file| + #rake_commands = commands.select { |command| command.name == "rake" } + commands.each do |command| + file.puts " " + Makit::CommandRunner.get_command_summary(command).strip_color_codes + file.puts " start time: #{command.started_at}" + file.puts command.output.strip_color_codes unless command.output.strip_color_codes.strip.length == 0 + file.puts command.error.strip_color_codes unless command.error.strip_color_codes.strip.length == 0 + file.puts " " + end + end + end + + def log_slowest_commands + dir = File.join(Makit::Directories::PROJECT_ARTIFACTS, "commands") + FileUtils.mkdir_p(dir) unless Dir.exist?(dir) + + # open a text file to write to + File.open(File.join(dir, "slow.commands.txt"), "w") do |file| + Makit::RUNNER.commands.sort_by { |command| (command.duration.seconds + (command.duration.nanos / 1_000_000_000.0)) }.reverse.first(5).each do |command| + # Convert to float representation + duration_in_float = command.duration.seconds + (command.duration.nanos / 1_000_000_000.0) + #puts " #{command.name} took #{duration_in_float} seconds" + file.puts " " + Makit::CommandRunner.get_command_summary(command).strip_color_codes + " (#{command.duration.seconds} seconds)" + end + end + end + end # class CommandRunner +end # module Makit