Switch to proper standalone executable with Thor

This commit is contained in:
David Heinemeier Hansson
2023-01-14 11:31:37 +01:00
parent bf98a0308c
commit fed64ef244
28 changed files with 387 additions and 307 deletions

View File

@@ -4,3 +4,4 @@ git_source(:github) { |repo| "https://github.com/#{repo}.git" }
gemspec
gem "debug"
gem "railties"

View File

@@ -2,8 +2,9 @@ PATH
remote: .
specs:
mrsk (0.0.3)
railties (>= 7.0.0)
activesupport (>= 7.0)
sshkit (~> 1.21)
thor (~> 1.2)
GEM
remote: https://rubygems.org/
@@ -46,11 +47,11 @@ GEM
net-scp (4.0.0)
net-ssh (>= 2.6.5, < 8.0.0)
net-ssh (7.0.1)
nokogiri (1.14.0.rc1-arm64-darwin)
nokogiri (1.14.0-arm64-darwin)
racc (~> 1.4)
nokogiri (1.14.0.rc1-x86_64-darwin)
nokogiri (1.14.0-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.14.0.rc1-x86_64-linux)
nokogiri (1.14.0-x86_64-linux)
racc (~> 1.4)
racc (1.6.2)
rack (2.2.5)
@@ -90,6 +91,7 @@ PLATFORMS
DEPENDENCIES
debug
mrsk!
railties
BUNDLED WITH
2.4.3

5
bin/mrsk Executable file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env ruby
require "mrsk/cli"
Mrsk::Cli::Main.start(ARGV)

View File

@@ -2,5 +2,4 @@ module Mrsk
end
require "mrsk/version"
require "mrsk/engine"
require "mrsk/commander"

9
lib/mrsk/cli.rb Normal file
View File

@@ -0,0 +1,9 @@
require "mrsk"
MRSK = Mrsk::Commander.new \
config_file: Pathname.new(File.expand_path("config/deploy.yml"))
module Mrsk::Cli
end
require "mrsk/cli/main"

98
lib/mrsk/cli/app.rb Normal file
View File

@@ -0,0 +1,98 @@
require "mrsk/cli/base"
class Mrsk::Cli::App < Mrsk::Cli::Base
desc "boot", "Boot app on servers (or start them if they've already been booted)"
def boot
MRSK.config.roles.each do |role|
on(role.hosts) do |host|
begin
execute *MRSK.app.run(role: role.name)
rescue SSHKit::Command::Failed => e
if e.message =~ /already in use/
error "Container with same version already deployed on #{host}, starting that instead"
execute *MRSK.app.start, host: host
else
raise
end
end
end
end
end
desc "start", "Start existing app on servers (use --version=<git-hash> to designate specific version)"
option :version, desc: "Defaults to the most recent git-hash in local repository"
def start
if (version = options[:version]).present?
on(MRSK.config.hosts) { execute *MRSK.app.start(version: version), raise_on_non_zero_exit: false }
else
on(MRSK.config.hosts) { execute *MRSK.app.start, raise_on_non_zero_exit: false }
end
end
desc "stop", "Stop app on servers"
def stop
on(MRSK.config.hosts) { execute *MRSK.app.stop, raise_on_non_zero_exit: false }
end
desc "restart", "Start app on servers (use VERSION=<git-hash> to designate which version)"
def restart
invoke :stop
invoke :start
end
desc "details", "Display details about app containers"
def details
on(MRSK.config.hosts) { |host| puts "App Host: #{host}\n" + capture(*MRSK.app.info, verbosity: Logger::INFO) + "\n\n" }
end
desc "exec [CMD]", "Execute a custom task on servers passed in as CMD='bin/rake some:task'"
option :once, type: :boolean, default: false
def exec(cmd)
if options[:once]
on(MRSK.config.primary_host) { puts capture(*MRSK.app.exec(cmd), verbosity: Logger::INFO) }
else
on(MRSK.config.hosts) { |host| puts "App Host: #{host}\n" + capture(*MRSK.app.exec(cmd), verbosity: Logger::INFO) + "\n\n" }
end
end
desc "console [HOST]", "Start Rails Console on primary host (or designated HOST)"
def console(host = MRSK.config.primary_host)
puts "Launching Rails console on #{host}..."
exec MRSK.app.console(host: host)
end
desc "runner [EXPRESSION]", "Execute Rails runner with given expression"
option :once, type: :boolean, default: false, desc:
def runner(expression)
if options[:once]
on(MRSK.config.primary_host) { puts capture(*MRSK.app.exec("bin/rails", "runner", "'#{expression}'"), verbosity: Logger::INFO) }
else
on(MRSK.config.hosts) { |host| puts "App Host: #{host}\n" + capture(*MRSK.app.exec("bin/rails", "runner", "'#{expression}'"), verbosity: Logger::INFO) + "\n\n" }
end
end
desc "containers", "List all the app containers currently on servers"
def containers
on(MRSK.config.hosts) { |host| puts "App Host: #{host}\n" + capture(*MRSK.app.list_containers) + "\n\n" }
end
desc "logs", "Show last 100 log lines from app on servers"
def logs
# FIXME: Catch when app containers aren't running
on(MRSK.config.hosts) { |host| puts "App Host: #{host}\n" + capture(*MRSK.app.logs) + "\n\n" }
end
desc "remove", "Remove app containers and images from servers"
option :only, default: "", desc: "Use 'containers' or 'images'"
def remove
case options[:only]
when "containers"
on(MRSK.config.hosts) { execute *MRSK.app.remove_containers }
when "images"
on(MRSK.config.hosts) { execute *MRSK.app.remove_images }
else
on(MRSK.config.hosts) { execute *MRSK.app.remove_containers }
on(MRSK.config.hosts) { execute *MRSK.app.remove_images }
end
end
end

27
lib/mrsk/cli/base.rb Normal file
View File

@@ -0,0 +1,27 @@
require "thor"
require "sshkit"
require "sshkit/dsl"
module Mrsk::Cli
class Base < Thor
include SSHKit::DSL
def self.exit_on_failure?() true end
class_option :verbose, type: :boolean, aliases: "-v", desc: "Detailed logging"
def initialize(*)
super
MRSK.verbose = options[:verbose]
end
private
def print_runtime
started_at = Time.now
yield
ensure
runtime = Time.now - started_at
puts " Finished all in #{sprintf("%.1f seconds", runtime)}"
end
end
end

53
lib/mrsk/cli/build.rb Normal file
View File

@@ -0,0 +1,53 @@
require "mrsk/cli/base"
class Mrsk::Cli::Build < Mrsk::Cli::Base
desc "deliver", "Deliver a newly built app image to servers"
def deliver
invoke :push
invoke :pull
end
desc "push", "Build locally and push app image to registry"
def push
run_locally do
begin
debug "Using builder: #{MRSK.builder.name}"
info "Building image may take a while (run with --verbose for progress logging)"
execute *MRSK.builder.push
rescue SSHKit::Command::Failed => e
error "Missing compatible builder, so creating a new one first"
execute *MRSK.builder.create
execute *MRSK.builder.push
end
end
end
desc "pull", "Pull app image from the registry onto servers"
def pull
on(MRSK.config.hosts) { execute *MRSK.builder.pull }
end
desc "create", "Create a local build setup"
def create
run_locally do
debug "Using builder: #{MRSK.builder.name}"
execute *MRSK.builder.create
end
end
desc "remove", "Remove local build setup"
def remove
run_locally do
debug "Using builder: #{MRSK.builder.name}"
execute *MRSK.builder.remove
end
end
desc "details", "Show the name of the configured builder"
def details
run_locally do
puts "Builder: #{MRSK.builder.name} (#{MRSK.builder.target.class.name})"
puts capture(*MRSK.builder.info)
end
end
end

87
lib/mrsk/cli/main.rb Normal file
View File

@@ -0,0 +1,87 @@
require "mrsk/cli/base"
require "mrsk/cli/app"
require "mrsk/cli/build"
require "mrsk/cli/prune"
require "mrsk/cli/registry"
require "mrsk/cli/server"
require "mrsk/cli/traefik"
class Mrsk::Cli::Main < Mrsk::Cli::Base
desc "ship", "Ship the app to servers"
def ship
print_runtime do
invoke "mrsk:cli:server:bootstrap"
invoke "mrsk:cli:registry:login"
invoke "mrsk:cli:build:deliver"
invoke "mrsk:cli:traefik:boot"
invoke "mrsk:cli:app:stop"
invoke "mrsk:cli:app:boot"
invoke "mrsk:cli:prune:all"
end
end
desc "reship", "Ship new version of the app to servers (without bootstrapping servers, starting Traefik, pruning, and registry login)"
def reship
print_runtime do
invoke "mrsk:cli:build:deliver"
invoke "mrsk:cli:app:stop"
invoke "mrsk:cli:app:boot"
end
end
desc "rollback [VERSION]", "Rollback the app to VERSION (that must already be on servers)"
def rollback(version)
invoke "mrsk:cli:app:restart"
end
desc "details", "Display details about Traefik and app containers"
def details
invoke "mrsk:cli:traefik:details"
invoke "mrsk:cli:app:details"
end
desc "install", "Create config stub in config/deploy.yml and binstub in bin/mrsk"
def install
require "fileutils"
if (deploy_file = Pathname.new(File.expand_path("config/deploy.yml"))).exist?
puts "Config file already exists in config/deploy.yml (remove first to create a new one)"
else
FileUtils.cp_r Pathname.new(File.expand_path("templates/deploy.yml", __dir__)), deploy_file
puts "Created configuration file in config/deploy.yml"
end
if (binstub = Pathname.new(File.expand_path("bin/mrsk"))).exist?
puts "Binstub already exists in bin/mrsk (remove first to create a new one)"
else
`bundle binstubs mrsk`
puts "Created binstub file in bin/mrsk"
end
end
desc "remove", "Remove Traefik, app, and registry session from servers"
def remove
invoke "mrsk:cli:traefik:remove"
invoke "mrsk:cli:app:remove"
invoke "mrsk:cli:registry:logout"
end
desc "app", "Manage the application"
subcommand "app", Mrsk::Cli::App
desc "build", "Build the application image"
subcommand "build", Mrsk::Cli::Build
desc "prune", "Prune old application images and containers"
subcommand "prune", Mrsk::Cli::Prune
desc "registry", "Login and out of the image registry"
subcommand "registry", Mrsk::Cli::Registry
desc "server", "Bootstrap servers with Docker"
subcommand "server", Mrsk::Cli::Server
desc "traefik", "Manage the Traefik load balancer"
subcommand "traefik", Mrsk::Cli::Traefik
end

19
lib/mrsk/cli/prune.rb Normal file
View File

@@ -0,0 +1,19 @@
require "mrsk/cli/base"
class Mrsk::Cli::Prune < Mrsk::Cli::Base
desc "all", "Prune unused images and stopped containers"
def all
invoke :containers
invoke :images
end
desc "images", "Prune unused images older than 30 days"
def images
on(MRSK.config.hosts) { execute *MRSK.prune.images }
end
desc "containers", "Prune stopped containers for the service older than 3 days"
def containers
on(MRSK.config.hosts) { execute *MRSK.prune.containers }
end
end

14
lib/mrsk/cli/registry.rb Normal file
View File

@@ -0,0 +1,14 @@
require "mrsk/cli/base"
class Mrsk::Cli::Registry < Mrsk::Cli::Base
desc "login", "Login to the registry locally and remotely"
def login
run_locally { execute *MRSK.registry.login }
on(MRSK.config.hosts) { execute *MRSK.registry.login }
end
desc "logout", "Logout of the registry remotely"
def logout
on(MRSK.config.hosts) { execute *MRSK.registry.logout }
end
end

8
lib/mrsk/cli/server.rb Normal file
View File

@@ -0,0 +1,8 @@
require "mrsk/cli/base"
class Mrsk::Cli::Server < Mrsk::Cli::Base
desc "bootstrap", "Ensure Docker is installed on the servers"
def bootstrap
on(MRSK.config.hosts) { execute "which docker || apt-get install docker.io -y" }
end
end

View File

@@ -18,7 +18,5 @@ env:
registry:
# Specify the registry server, if you're not using Docker Hub
# server: registry.digitalocean.com / ghcr.io / ...
# Set credentials with bin/rails credentials:edit
username: my-user
password: my-password-should-go-in-credentials
password: my-password-should-go-somewhere-safe

44
lib/mrsk/cli/traefik.rb Normal file
View File

@@ -0,0 +1,44 @@
require "mrsk/cli/base"
class Mrsk::Cli::Traefik < Mrsk::Cli::Base
desc "boot", "Boot Traefik on servers"
def boot
on(MRSK.config.role(:web).hosts) { execute *MRSK.traefik.run, raise_on_non_zero_exit: false }
end
desc "start", "Start existing Traefik on servers"
def start
on(MRSK.config.role(:web).hosts) { execute *MRSK.traefik.start, raise_on_non_zero_exit: false }
end
desc "stop", "Stop Traefik on servers"
def stop
on(MRSK.config.role(:web).hosts) { execute *MRSK.traefik.stop, raise_on_non_zero_exit: false }
end
desc "restart", "Restart Traefik on servers"
def restart
invoke :stop
invoke :start
end
desc "details", "Display details about Traefik containers from servers"
def details
on(MRSK.config.role(:web).hosts) { |host| puts "Traefik Host: #{host}\n" + capture(*MRSK.traefik.info) + "\n\n" }
end
desc "logs", "Show last 100 log lines from Traefik on servers"
def logs
on(MRSK.config.hosts) { |host| puts "Traefik Host: #{host}\n" + capture(*MRSK.traefik.logs) + "\n\n" }
end
desc "remove", "Remove Traefik container and image from servers"
def remove
invoke :stop
on(MRSK.config.role(:web).hosts) do
execute *MRSK.traefik.remove_container
execute *MRSK.traefik.remove_image
end
end
end

View File

@@ -6,14 +6,15 @@ require "mrsk/commands/traefik"
require "mrsk/commands/registry"
class Mrsk::Commander
attr_reader :config_file, :config, :verbose
attr_reader :config
attr_accessor :verbose
def initialize(config_file:, verbose: false)
@config_file, @verbose = config_file, verbose
def initialize(config_file:)
@config_file = config_file
end
def config
@config ||= Mrsk::Configuration.load_file(config_file).tap { |config| setup_with(config) }
@config ||= Mrsk::Configuration.load_file(@config_file).tap { |config| setup_with(config) }
end

View File

@@ -15,8 +15,8 @@ class Mrsk::Commands::App < Mrsk::Commands::Base
role.cmd
end
def start
docker :start, config.service_with_version
def start(version: config.version)
docker :start, "#{config.service}-#{version}"
end
def stop
@@ -40,8 +40,8 @@ class Mrsk::Commands::App < Mrsk::Commands::Base
*command
end
def console
"ssh -t #{config.ssh_user}@#{config.primary_host} '#{exec("bin/rails", "c", interactive: true).join(" ")}'"
def console(host: config.primary_host)
"ssh -t #{config.ssh_user}@#{host} '#{exec("bin/rails", "c", interactive: true).join(" ")}'"
end
def list_containers

View File

@@ -1,5 +1,7 @@
require "active_support/ordered_options"
require "active_support/core_ext/string/inquiry"
require "active_support/core_ext/module/delegation"
require "pathname"
require "erb"
class Mrsk::Configuration
@@ -91,7 +93,7 @@ class Mrsk::Configuration
end
def master_key
ENV["RAILS_MASTER_KEY"] || File.read(Rails.root.join("config/master.key"))
ENV["RAILS_MASTER_KEY"] || File.read(Pathname.new(File.expand_path("config/master.key")))
end

View File

@@ -1,4 +0,0 @@
module Mrsk
class Engine < ::Rails::Engine
end
end

View File

@@ -1,97 +0,0 @@
require_relative "setup"
namespace :mrsk do
namespace :app do
desc "Run app on servers (or start them if they've already been run)"
task :run do
MRSK.config.roles.each do |role|
on(role.hosts) do |host|
begin
execute *MRSK.app.run(role: role.name)
rescue SSHKit::Command::Failed => e
if e.message =~ /already in use/
error "Container with same version already deployed on #{host}, starting that instead"
execute *MRSK.app.start, host: host
else
raise
end
end
end
end
end
desc "Start existing app on servers (use VERSION=<git-hash> to designate which version)"
task :start do
on(MRSK.config.hosts) { execute *MRSK.app.start, raise_on_non_zero_exit: false }
end
desc "Stop app on servers"
task :stop do
on(MRSK.config.hosts) { execute *MRSK.app.stop, raise_on_non_zero_exit: false }
end
desc "Start app on servers (use VERSION=<git-hash> to designate which version)"
task restart: %i[ stop start ]
desc "Display information about app containers"
task :info do
on(MRSK.config.hosts) { |host| puts "App Host: #{host}\n" + capture(*MRSK.app.info) + "\n\n" }
end
desc "Execute a custom task on servers passed in as CMD='bin/rake some:task'"
task :exec do
on(MRSK.config.hosts) { |host| puts "App Host: #{host}\n" + capture(*MRSK.app.exec(ENV["CMD"])) + "\n\n" }
end
desc "Start Rails Console on primary host"
task :console do
puts "Launching Rails console on #{MRSK.config.primary_host}..."
exec app.console
end
namespace :exec do
desc "Execute Rails command on servers, like CMD='runner \"puts %(Hello World)\""
task :rails do
on(MRSK.config.hosts) { |host| puts "App Host: #{host}\n" + capture(*MRSK.app.exec("bin/rails", ENV["CMD"])) + "\n\n" }
end
desc "Execute a custom task on the first defined server"
task :once do
on(MRSK.config.primary_host) { puts capture(*MRSK.app.exec(ENV["CMD"])) }
end
namespace :once do
desc "Execute Rails command on the first defined server, like CMD='runner \"puts %(Hello World)\""
task :rails do
on(MRSK.config.primary_host) { puts capture(*MRSK.app.exec("bin/rails", ENV["CMD"])) }
end
end
end
desc "List all the app containers currently on servers"
task :containers do
on(MRSK.config.hosts) { |host| puts "App Host: #{host}\n" + capture(*MRSK.app.list_containers) + "\n\n" }
end
desc "Show last 100 log lines from app on servers"
task :logs do
# FIXME: Catch when app containers aren't running
on(MRSK.config.hosts) { |host| puts "App Host: #{host}\n" + capture(*MRSK.app.logs) + "\n\n" }
end
desc "Remove app containers and images from servers"
task remove: %w[ remove:containers remove:images ]
namespace :remove do
desc "Remove app containers from servers"
task :containers do
on(MRSK.config.hosts) { execute *MRSK.app.remove_containers }
end
desc "Remove app images from servers"
task :images do
on(MRSK.config.hosts) { execute *MRSK.app.remove_images }
end
end
end
end

View File

@@ -1,52 +0,0 @@
require_relative "setup"
namespace :mrsk do
namespace :build do
desc "Deliver a newly built app image to servers"
task deliver: %i[ push pull ]
desc "Build locally and push app image to registry"
task :push do
run_locally do
begin
debug "Using builder: #{MRSK.builder.name}"
info "Building image may take a while (run with VERBOSE=1 for progress logging)"
execute *MRSK.builder.push
rescue SSHKit::Command::Failed => e
error "Missing compatible builder, so creating a new one first"
execute *MRSK.builder.create
execute *MRSK.builder.push
end
end unless ENV["VERSION"]
end
desc "Pull app image from the registry onto servers"
task :pull do
on(MRSK.config.hosts) { execute *MRSK.builder.pull }
end
desc "Create a local build setup"
task :create do
run_locally do
debug "Using builder: #{MRSK.builder.name}"
execute *MRSK.builder.create
end
end
desc "Remove local build setup"
task :remove do
run_locally do
debug "Using builder: #{MRSK.builder.name}"
execute *MRSK.builder.remove
end
end
desc "Show the name of the configured builder"
task :info do
run_locally do
puts "Builder: #{MRSK.builder.name} (#{MRSK.builder.target.class.name})"
puts capture(*MRSK.builder.info)
end
end
end
end

View File

@@ -1,37 +0,0 @@
require_relative "setup"
namespace :mrsk do
desc "Ship the app to servers that will have Docker installed if missing"
task ship: %w[ server:bootstrap deploy ]
desc "Push the latest version of the app, ensure Traefik is running, then restart app"
task deploy: %w[ registry:login build:deliver traefik:run app:stop app:run prune ]
desc "Rollback to VERSION=x that was already run as a container on servers"
task rollback: %w[ app:restart ]
desc "Display information about Traefik and app containers"
task info: %w[ traefik:info app:info ]
desc "Create config stub in config/deploy.yml"
task :init do
require "fileutils"
if (deploy_file = Rails.root.join("config/deploy.yml")).exist?
puts "Config file already exists in config/deploy.yml (remove first to create a new one)"
else
FileUtils.cp_r Pathname.new(File.expand_path("templates/deploy.yml", __dir__)), deploy_file
puts "Created configuration file in config/deploy.yml"
end
if (binstub = Rails.root.join("bin/mrsk")).exist?
puts "Binstub already exists in bin/mrsk (remove first to create a new one)"
else
FileUtils.cp_r Pathname.new(File.expand_path("templates/mrsk", __dir__)), binstub
puts "Created binstub file in bin/mrsk"
end
end
desc "Remove Traefik, app, and registry session from servers"
task remove: %w[ traefik:remove app:remove registry:logout ]
end

View File

@@ -1,18 +0,0 @@
require_relative "setup"
namespace :mrsk do
desc "Prune unused images and stopped containers"
task prune: %w[ prune:containers prune:images ]
namespace :prune do
desc "Prune unused images older than 30 days"
task :images do
on(MRSK.config.hosts) { execute *MRSK.prune.images }
end
desc "Prune stopped containers for the service older than 3 days"
task :containers do
on(MRSK.config.hosts) { execute *MRSK.prune.containers }
end
end
end

View File

@@ -1,16 +0,0 @@
require_relative "setup"
namespace :mrsk do
namespace :registry do
desc "Login to the registry locally and remotely"
task :login do
run_locally { execute *MRSK.registry.login }
on(MRSK.config.hosts) { execute *MRSK.registry.login }
end
desc "Logout of the registry remotely"
task :logout do
on(MRSK.config.hosts) { execute *MRSK.registry.logout }
end
end
end

View File

@@ -1,10 +0,0 @@
require_relative "setup"
namespace :mrsk do
namespace :server do
desc "Setup Docker on the remote servers"
task :bootstrap do
on(MRSK.config.hosts) { execute "which docker || apt-get install docker.io -y" }
end
end
end

View File

@@ -1,6 +0,0 @@
require "sshkit"
require "sshkit/dsl"
include SSHKit::DSL
MRSK = Mrsk::Commander.new config_file: Rails.root.join("config/deploy.yml"), verbose: ENV["VERBOSE"]

View File

@@ -1,8 +0,0 @@
#!/bin/bash
if [ "${*}" == "" ]; then
# Improve so list matches
exec bin/rake -T mrsk
else
exec bin/rake "mrsk:$@"
fi

View File

@@ -1,41 +0,0 @@
require_relative "setup"
namespace :mrsk do
namespace :traefik do
desc "Run Traefik on servers"
task :run do
on(MRSK.config.role(:web).hosts) { execute *MRSK.traefik.run, raise_on_non_zero_exit: false }
end
desc "Start existing Traefik on servers"
task :start do
on(MRSK.config.role(:web).hosts) { execute *MRSK.traefik.start, raise_on_non_zero_exit: false }
end
desc "Stop Traefik on servers"
task :stop do
on(MRSK.config.role(:web).hosts) { execute *MRSK.traefik.stop, raise_on_non_zero_exit: false }
end
desc "Restart Traefik on servers"
task restart: %i[ stop start ]
desc "Display information about Traefik containers from servers"
task :info do
on(MRSK.config.role(:web).hosts) { |host| puts "Traefik Host: #{host}\n" + capture(*MRSK.traefik.info) + "\n\n" }
end
desc "Show last 100 log lines from Traefik on servers"
task :logs do
on(MRSK.config.hosts) { |host| puts "Traefik Host: #{host}\n" + capture(*MRSK.traefik.logs) + "\n\n" }
end
desc "Remove Traefik container and image from servers"
task remove: %i[ stop ] do
on(MRSK.config.role(:web).hosts) do
execute *MRSK.traefik.remove_container
execute *MRSK.traefik.remove_image
end
end
end
end

View File

@@ -10,7 +10,9 @@ Gem::Specification.new do |spec|
spec.license = "MIT"
spec.files = Dir["lib/**/*", "MIT-LICENSE", "README.md"]
spec.executables = %w[ mrsk ]
spec.add_dependency "railties", ">= 7.0.0"
spec.add_dependency "activesupport", ">= 7.0"
spec.add_dependency "sshkit", "~> 1.21"
spec.add_dependency "thor", "~> 1.2"
end