require 'travis/cli'
require 'travis/tools/system'
require 'travis/tools/formatter'
require 'travis/tools/assets'
require 'travis/tools/completion'
require 'travis/version'
require 'highline'
require 'forwardable'
require 'yaml'
require 'timeout'
module Travis
module CLI
class Command
MINUTE = 60
HOUR = 3600
DAY = 86_400
WEEK = 604_800
include Tools::Assets
extend Tools::Assets
extend Forwardable
extend Parser
def_delegators :terminal, :agree, :ask, :choose
HighLine.use_color = Tools::System.unix? && $stdout.tty?
HighLine.color_scheme = HighLine::ColorScheme.new do |cs|
cs[:command] = [:bold]
cs[:error] = [:red]
cs[:important] = %i[bold underline]
cs[:success] = [:green]
cs[:info] = [:yellow]
cs[:debug] = [:magenta]
end
on('-h', '--help', 'Display help') do |c, _|
c.say c.help
exit
end
on('-i', '--[no-]interactive', 'be interactive and colorful') do |c, v|
HighLine.use_color = v if Tools::System.unix?
c.force_interactive = v
end
on('-E', '--[no-]explode', "don't rescue exceptions")
on('--skip-version-check', "don't check if travis client is up to date")
on('--skip-completion-check', "don't check if auto-completion is set up")
def self.command_name
name[/[^:]*$/].split(/(?=[A-Z])/).map(&:downcase).join('-')
end
@@abstract ||= [Command] # ignore the man behind the courtains!
def self.abstract?
@@abstract.include? self
end
def self.abstract
@@abstract << self
end
def self.skip(*names)
names.each { |n| define_method(n) {} }
end
def self.description(description = nil)
@description = description if description
@description ||= ''
end
def self.subcommands(*list)
return @subcommands ||= [] if list.empty?
@subcommands = list
define_method :run do |subcommand, *args|
error "Unknown subcommand. Available: #{list.join(', ')}." unless list.include? subcommand.to_sym
send(subcommand, *args)
end
define_method :usage do
usages = list.map { |c| color(usage_for("#{command_name} #{c}", c), :command) }
"\nUsage: #{usages.join("\n ")}\n\n"
end
end
attr_accessor :arguments, :config, :force_interactive, :formatter, :debug
attr_reader :input, :output
alias debug? debug
def initialize(options = {})
@on_signal = []
@formatter = Travis::Tools::Formatter.new
self.output = $stdout
self.input = $stdin
options.each do |key, value|
public_send("#{key}=", value) if respond_to? "#{key}="
end
@arguments ||= []
end
def terminal
@terminal ||= HighLine.new(input, output)
end
def input=(io)
@terminal = nil
@input = io
end
def output=(io)
@terminal = nil
@output = io
end
def write_to(io)
io_was = output
self.output = io
yield
ensure
self.output = io_was if io_was
end
def parse(args)
rest = parser.parse(args)
arguments.concat(rest)
rescue OptionParser::ParseError => e
error e.message
end
def setup; end
def last_check
config['last_check'] ||= {
# migrate from old values
'at' => config.delete('last_version_check'),
'etag' => config.delete('etag')
}
end
def check_version
last_check.clear if last_check['version'] != Travis::VERSION
seconds_since = Time.now.to_i - last_check['at'].to_i
return if skip_version_check?
return if seconds_since < MINUTE
timeout = case seconds_since
when MINUTE..HOUR then 0.5
when HOUR..DAY then 1.0
when DAY..WEEK then 2.0
else 10.0
end
Timeout.timeout(timeout) do
response = Faraday.get('https://rubygems.org/api/v1/gems/travis.json', {},
'If-None-Match' => last_check['etag'].to_s)
last_check['etag'] = response.headers['etag']
last_check['version'] = JSON.parse(response.body)['version'] if response.status == 200
end
last_check['at'] = Time.now.to_i
unless Tools::System.recent_version? Travis::VERSION, last_check['version']
warn 'Outdated CLI version, run `gem install travis`.'
end
rescue Timeout::Error, Faraday::ClientError => e
debug "#{e.class}: #{e.message}"
rescue JSON::ParserError
warn 'Unable to determine the most recent travis gem version. http://rubygems.org may be down.'
end
def check_completion
return if skip_completion_check? || !interactive?
if config['checked_completion']
Tools::Completion.update_completion if config['completion_version'] != Travis::VERSION
else
write_to($stderr) do
next Tools::Completion.update_completion if Tools::Completion.completion_installed?
next unless agree('Shell completion not installed. Would you like to install it now? ') do |q|
q.default = 'y'
end
Tools::Completion.install_completion
end
end
config['checked_completion'] = true
config['completion_version'] = Travis::VERSION
end
def check_ruby
return if (RUBY_VERSION > '1.9.2') || skip_version_check?
warn "Your Ruby version is outdated, please consider upgrading, as we will drop support for #{RUBY_VERSION} soon!"
end
def execute
setup_trap
check_ruby
check_arity(method(:run), *arguments)
load_config
check_version
check_completion
setup
run(*arguments)
clear_error
store_config
rescue Travis::Client::NotLoggedIn => e
raise(e) if explode?
error "#{e.message} - try running #{command("login#{endpoint_option}")}"
rescue Travis::Client::RepositoryMigrated => e
raise(e) if explode?
error e.message
rescue Travis::Client::NotFound => e
raise(e) if explode?
error "resource not found (#{e.message})"
rescue Travis::Client::Error => e
raise(e) if explode?
error e.message
rescue StandardError => e
raise(e) if explode?
message = e.message
if interactive?
message += color("\nfor a full error report, run #{command("report#{endpoint_option}")}",
:error)
end
store_error(e)
error(message)
end
def command_name
self.class.command_name
end
def usage
'Usage: ' << color(usage_for(command_name, :run), :command)
end
def usage_for(prefix, method)
usage = "travis #{prefix}"
method = method(method)
if method.respond_to? :parameters
method.parameters.each do |type, name|
name = name.upcase
name = "[#{name}]" if type == :opt
name = "[#{name}..]" if type == :rest
usage << " #{name}"
end
elsif method.arity != 0
usage << ' ...'
end
usage << ' [OPTIONS]'
end
def help(info = '')
parser.banner = usage
"#{self.class.description.sub(/./) { |c| c.upcase }}.\n#{info}#{parser}"
end
def say(data, format = nil, style = nil)
terminal.say format(data, format, style)
end
def debug(line)
return unless debug?
write_to($stderr) do
say color("** #{line}", :debug)
end
end
def time(info, callback = Proc.new)
return callback.call unless debug?
start = Time.now
debug(info)
callback.call
duration = Time.now - start
debug(' took %.2g seconds' % duration)
end
def info(line)
write_to($stderr) do
say color(line, :info)
end
end
def on_signal(&block)
@on_signal << block
end
def warn(message)
write_to($stderr) do
say color(message, :error)
yield if block_given?
end
end
def error(message, &block)
warn(message, &block)
exit 1
end
private
def store_error(exception)
message = format("An error occurred running `travis %s%s`:\n %p: %s\n", command_name, endpoint_option,
exception.class, exception.message)
exception.backtrace.each { |l| message << " from #{l}\n" }
save_file('error.log', message)
end
def clear_error
delete_file('error.log')
end
def setup_trap
%i[INT TERM].each do |signal|
trap signal do
@on_signal.each { |c| c.call }
exit 1
end
end
end
def format(data, format = nil, style = nil)
style ||= :important
data = format % color(data, style) if format && interactive?
data = data.gsub(/<\[\[/, '<%=').gsub(/\]\]>/, '%>')
data.encode! 'utf-8' if data.respond_to? :encode!
data
end
def template(*args)
File.read(*args).split('__END__', 2)[1].strip
end
def color(line, style)
return line.to_s unless interactive?
terminal.color(line || '???', Array(style).map(&:to_sym))
end
def interactive?(io = output)
return io.tty? if force_interactive.nil?
force_interactive
end
def empty_line
say "\n"
end
def command(name)
color("#{File.basename($PROGRAM_NAME)} #{name}", :command)
end
def success(line)
say color(line, :success) if interactive?
end
def config_path(name)
path = ENV.fetch('TRAVIS_CONFIG_PATH') { File.expand_path('.travis', Dir.home) }
Dir.mkdir(path, 0o700) unless File.directory? path
File.join(path, name)
end
def load_file(name, default = nil)
return default unless (path = config_path(name)) && File.exist?(path)
debug 'Loading %p' % path
File.read(path)
end
def delete_file(name)
return unless (path = config_path(name)) && File.exist?(path)
debug 'Deleting %p' % path
File.delete(path)
end
def save_file(name, content, read_only = false)
path = config_path(name)
debug 'Storing %p' % path
File.open(path, 'w') do |file|
file.write(content.to_s)
file.chmod(0o600) if read_only
end
end
YAML_ERROR = defined?(Psych::SyntaxError) ? Psych::SyntaxError : ArgumentError
def load_config
@config = YAML.load load_file('config.yml', '{}')
@config ||= {}
@original_config = @config.dup
rescue YAML_ERROR => e
raise e if explode?
warn "Broken config file: #{color config_path('config.yml'), :bold}"
exit 1 unless interactive? && agree('Remove config file? ') { |q| q.default = 'no' }
@original_config = {}
@config = {}
end
def store_config
save_file('config.yml', @config.to_yaml, true)
end
def check_arity(method, *args)
return unless method.respond_to? :parameters
method.parameters.each do |type, _name|
return if type == :rest
wrong_args('few') unless args.shift || (type == :opt) || (type == :block)
end
wrong_args('many') if args.any?
end
def danger_zone?(message)
agree(color('DANGER ZONE: ', %i[red bold]) << message << ' ') { |q| q.default = 'no' }
end
def write_file(file, content, force = false)
error "#{file} already exists" unless write_file?(file, force)
File.write(file, content)
end
def write_file?(file, force)
return true if force || !File.exist?(file)
return false unless interactive?
danger_zone? "Override existing #{color(file, :info)}?"
end
def wrong_args(quantity)
error "too #{quantity} arguments" do
say help
end
end
def endpoint_option
''
end
end
end
end