heroku-commander 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore ADDED
@@ -0,0 +1,5 @@
1
+ *.gem
2
+ .bundle
3
+ Gemfile.lock
4
+ pkg/*
5
+ .rbx
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --color
2
+ --format=documentation
3
+
data/.travis.yml ADDED
@@ -0,0 +1,9 @@
1
+ rvm:
2
+ - 1.9.2
3
+ - 1.9.3
4
+ - jruby
5
+ - rbx
6
+
7
+ notifications:
8
+ email:
9
+ - dblock@dblock.org
data/CHANGELOG.md ADDED
@@ -0,0 +1,4 @@
1
+ 0.1.0 (01/31/2013)
2
+ ==================
3
+
4
+ * Initial public release with support for `heroku config`, `heroku run` and `heroku run:detached` - [@dblock](https://github.com/dblock), [@macreery](https://github.com/macreery).
data/Gemfile ADDED
@@ -0,0 +1,10 @@
1
+ source "http://rubygems.org"
2
+
3
+ gemspec
4
+
5
+ group :development, :test do
6
+ gem "heroku"
7
+ gem "rake"
8
+ gem "bundler"
9
+ gem "rspec"
10
+ end
data/LICENSE.md ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2012 Daniel Doubrovkine, Artsy Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,73 @@
1
+ ![](assets/heroku-commander.png)
2
+ Heroku::Commander [![Build Status](https://travis-ci.org/dblock/heroku-commander.png?branch=master)](https://travis-ci.org/dblock/heroku-commander)
3
+ =================
4
+
5
+ Master the Heroku CLI from Ruby.
6
+
7
+ Usage
8
+ -----
9
+
10
+ Add `heroku` and `heroku-commander` to Gemfile.
11
+
12
+ ``` ruby
13
+ gem "heroku"
14
+ gem "heroku-commander"
15
+ ```
16
+
17
+ Heroku Configuration
18
+ --------------------
19
+
20
+ Returns a hash of an application's configuration (output from `heroku config`).
21
+
22
+
23
+ ``` ruby
24
+ commander = Heroku::Commander.new({ :app => "heroku-commander" })
25
+ commander.config # => a hash of all settings for the heroku-commander app
26
+ ```
27
+
28
+ Heroku Run
29
+ ----------
30
+
31
+ Executes a command via `heroku run`, pipes and returns output lines. Unlike the heroku client, this also checks the process return code and raises a `Heroku::Commander::Errors::CommandError` if the latter is not zero, which makes this suitable for Rake tasks.
32
+
33
+ ``` ruby
34
+ commander = Heroku::Commander.new({ :app => "heroku-commander" })
35
+ commander.run "uname -a" # => [ "Linux 2.6.32-348-ec2 #54-Ubuntu SMP x86_64 GNU" ]
36
+ ```
37
+
38
+ Heroku Detached Run
39
+ -------------------
40
+
41
+ Executes a command via `heroku run:detached`, spawns a `heroku logs --tail -p pid` for the process started on Heroku, pipes and returns output lines. This also checks the process return code and raises a `Heroku::Commander::Errors::CommandError` if the latter is not zero.
42
+
43
+ ``` ruby
44
+ commander = Heroku::Commander.new({ :app => "heroku-commander" })
45
+ commander.run("uname -a", { :detached => true }) # => [ "Linux 2.6.32-348-ec2 #54-Ubuntu SMP x86_64 GNU" ]
46
+ ```
47
+
48
+ You can examine the output from `heroku logs --tail -p pid` line-by-line.
49
+
50
+ ``` ruby
51
+ commander.run("ls -R", { :detached => true }) do |line|
52
+ # each line from the output of the command
53
+ end
54
+ ```
55
+
56
+ For more information about Heroku one-off dynos see [this documentation](https://devcenter.heroku.com/articles/one-off-dynos).
57
+
58
+ More Examples
59
+ -------------
60
+
61
+ See [examples](examples) for more.
62
+
63
+ Contributing
64
+ ------------
65
+
66
+ Fork the project. Make your feature addition or bug fix with tests. Send a pull request. Bonus points for topic branches.
67
+
68
+ Copyright and License
69
+ ---------------------
70
+
71
+ MIT License, see [LICENSE](LICENSE.md) for details.
72
+
73
+ (c) 2013 [Daniel Doubrovkine](http://github.com/dblock), [Frank Macreery](http://github.com/macreery), [Artsy Inc.](http://artsy.net)
data/Rakefile ADDED
@@ -0,0 +1,22 @@
1
+ require 'rubygems'
2
+ require 'bundler/gem_tasks'
3
+
4
+ begin
5
+ Bundler.setup(:default, :development)
6
+ rescue Bundler::BundlerError => e
7
+ $stderr.puts e.message
8
+ $stderr.puts "Run `bundle install` to install missing gems"
9
+ exit e.status_code
10
+ end
11
+
12
+ require 'rake'
13
+
14
+ require 'rspec/core'
15
+ require 'rspec/core/rake_task'
16
+
17
+ RSpec::Core::RakeTask.new(:spec) do |spec|
18
+ spec.pattern = FileList['spec/**/*_spec.rb']
19
+ end
20
+
21
+ task :default => :spec
22
+
Binary file
@@ -0,0 +1,12 @@
1
+ require 'bundler'
2
+ Bundler.setup(:default, :development)
3
+
4
+ require 'heroku-commander'
5
+
6
+ logger = Logger.new($stdout)
7
+ logger.level = Logger::DEBUG
8
+ commander = Heroku::Commander.new({ :logger => logger })
9
+ config = commander.config
10
+ config.each_pair do |name, value|
11
+ logger.info "#{name}: #{value}"
12
+ end
@@ -0,0 +1,17 @@
1
+ require 'bundler'
2
+ Bundler.setup(:default, :development)
3
+
4
+ require 'heroku-commander'
5
+
6
+ logger = Logger.new($stdout)
7
+ logger.level = Logger::DEBUG
8
+ commander = Heroku::Commander.new({ :logger => logger })
9
+
10
+ uname = commander.run "uname -a", { :detached => true }
11
+ logger.info "Heroku dyno is a #{uname.join('\n')}."
12
+
13
+ files = []
14
+ commander.run "ls -1", { :detached => true } do |line|
15
+ files << line
16
+ end
17
+ logger.info "The Heroku file system has #{files.count} file(s): #{files.join(', ')}"
@@ -0,0 +1,17 @@
1
+ require 'bundler'
2
+ Bundler.setup(:default, :development)
3
+
4
+ require 'heroku-commander'
5
+
6
+ logger = Logger.new($stdout)
7
+ logger.level = Logger::DEBUG
8
+ commander = Heroku::Commander.new({ :logger => logger })
9
+
10
+ uname = commander.run "uname -a"
11
+ logger.info "Heroku dyno is a #{uname.join('\n')}."
12
+
13
+ files = []
14
+ commander.run "ls -1" do |line|
15
+ files << line
16
+ end
17
+ logger.info "The Heroku file system has #{files.count} file(s): #{files.join(', ')}"
@@ -0,0 +1,17 @@
1
+ $:.push File.expand_path("../lib", __FILE__)
2
+ require "heroku/commander/version"
3
+
4
+ Gem::Specification.new do |s|
5
+ s.name = "heroku-commander"
6
+ s.version = Heroku::Commander::VERSION
7
+ s.authors = [ "Daniel Doubrovkine", "Frank Macreery" ]
8
+ s.email = "dblock@dblock.org"
9
+ s.platform = Gem::Platform::RUBY
10
+ s.required_rubygems_version = '>= 1.3.6'
11
+ s.files = `git ls-files`.split("\n")
12
+ s.require_paths = [ "lib" ]
13
+ s.homepage = "http://github.com/dblock/heroku-commander"
14
+ s.licenses = [ "MIT" ]
15
+ s.summary = "Control Heroku from Ruby via its `heroku` shell command."
16
+ s.add_dependency "i18n"
17
+ end
@@ -0,0 +1,26 @@
1
+ en:
2
+ heroku:
3
+ commander:
4
+ errors:
5
+ messages:
6
+ client_eio:
7
+ message: "Heroku client stopped sending output."
8
+ summary: "The heroku client stopped sending output before exiting."
9
+ resolution: "This is unexpected and represents a Heroku server error. Try again."
10
+ command_error:
11
+ message: "The command `%{cmd}` failed with exit status %{status}."
12
+ summary: "%{message}%{lines}"
13
+ resolution: "Examine the command line and refer to the command documentation."
14
+ unexpected_output:
15
+ message: "The command `%{cmd}` returned unexpected output."
16
+ summary: "The output unexpectedly contained %{line}."
17
+ resolution: "This is unexpected and could represent a Heroku server error. Try again."
18
+ missing_command_error:
19
+ message: "Missing command."
20
+ summary: "The instance of Heroku::Runner is missing a command argument."
21
+ resolution: "Specify a command, for example:\n
22
+ \_\_Heroku::Runner.new({ :command => 'ls -1' })"
23
+ already_running_error:
24
+ message: "The process is already running with pid %{pid}."
25
+ summary: "You can only run! or detach! an instance of Heroku::Runner once."
26
+ resolution: "This is a bug in your code."
@@ -0,0 +1,15 @@
1
+ require 'i18n'
2
+
3
+ I18n.load_path << File.join(File.dirname(__FILE__), "config", "locales", "en.yml")
4
+
5
+ require 'pty'
6
+ require 'heroku/pty.rb'
7
+ require 'logger'
8
+
9
+ require 'heroku/commander/version'
10
+ require 'heroku/commander/errors'
11
+ require 'heroku/config'
12
+ require 'heroku/commander'
13
+ require 'heroku/executor'
14
+ require 'heroku/runner'
15
+
@@ -0,0 +1,25 @@
1
+ module Heroku
2
+ class Commander
3
+
4
+ attr_accessor :app, :logger, :config
5
+
6
+ def initialize(options = {})
7
+ @app = options[:app]
8
+ @logger = options[:logger]
9
+ end
10
+
11
+ # Returns a loaded Heroku::Config instance.
12
+ def config
13
+ @config ||= Heroku::Config.new({ :app => app, :logger => logger }).tap do |config|
14
+ config.reload!
15
+ end
16
+ end
17
+
18
+ # Run a process synchronously
19
+ def run(command, options = {}, &block)
20
+ runner = Heroku::Runner.new({ :app => app, :logger => logger, :command => command })
21
+ runner.run!(options, &block)
22
+ end
23
+
24
+ end
25
+ end
@@ -0,0 +1,6 @@
1
+ require 'heroku/commander/errors/base'
2
+ require 'heroku/commander/errors/client_eio_error'
3
+ require 'heroku/commander/errors/command_error'
4
+ require 'heroku/commander/errors/missing_command_error'
5
+ require 'heroku/commander/errors/unexpected_output_error'
6
+ require 'heroku/commander/errors/already_running_error'
@@ -0,0 +1,13 @@
1
+ module Heroku
2
+ class Commander
3
+ module Errors
4
+ class AlreadyRunningError < Heroku::Commander::Errors::Base
5
+
6
+ def initialize(opts)
7
+ super(compose_message("already_running_error", opts))
8
+ end
9
+
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,81 @@
1
+ module Heroku
2
+ class Commander
3
+ module Errors
4
+ class Base < StandardError
5
+
6
+ # Problem occurred.
7
+ attr_reader :problem
8
+
9
+ # Summary of the problem.
10
+ attr_reader :summary
11
+
12
+ # Suggested problem resolution.
13
+ attr_reader :resolution
14
+
15
+ # Compose the message.
16
+ # === Parameters
17
+ # [key] Lookup key in the translation table.
18
+ # [attributes] The objects to pass to create the message.
19
+ def compose_message(key, attributes = {})
20
+ @problem = create_problem(key, attributes)
21
+ @summary = create_summary(key, attributes)
22
+ @resolution = create_resolution(key, attributes)
23
+
24
+ "\nProblem:\n #{@problem}"+
25
+ "\nSummary:\n #{@summary}"+
26
+ "\nResolution:\n #{@resolution}"
27
+ end
28
+
29
+ private
30
+
31
+ BASE_KEY = "heroku.commander.errors.messages" #:nodoc:
32
+
33
+ # Given the key of the specific error and the options hash, translate the
34
+ # message.
35
+ #
36
+ # === Parameters
37
+ # [key] The key of the error in the locales.
38
+ # [options] The objects to pass to create the message.
39
+ #
40
+ # Returns a localized error message string.
41
+ def translate(key, options)
42
+ ::I18n.translate("#{BASE_KEY}.#{key}", { :locale => :en }.merge(options)).strip
43
+ end
44
+
45
+ # Create the problem.
46
+ #
47
+ # === Parameters
48
+ # [key] The error key.
49
+ # [attributes] The attributes to interpolate.
50
+ #
51
+ # Returns the problem.
52
+ def create_problem(key, attributes)
53
+ translate("#{key}.message", attributes)
54
+ end
55
+
56
+ # Create the summary.
57
+ #
58
+ # === Parameters
59
+ # [key] The error key.
60
+ # [attributes] The attributes to interpolate.
61
+ #
62
+ # Returns the summary.
63
+ def create_summary(key, attributes)
64
+ translate("#{key}.summary", attributes)
65
+ end
66
+
67
+ # Create the resolution.
68
+ #
69
+ # === Parameters
70
+ # [key] The error key.
71
+ # [attributes] The attributes to interpolate.
72
+ #
73
+ # Returns the resolution.
74
+ def create_resolution(key, attributes)
75
+ translate("#{key}.resolution", attributes)
76
+ end
77
+
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,11 @@
1
+ module Heroku
2
+ class Commander
3
+ module Errors
4
+ class ClientEIOError < Heroku::Commander::Errors::Base
5
+ def initialize
6
+ super(compose_message("client_eio"))
7
+ end
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,34 @@
1
+ module Heroku
2
+ class Commander
3
+ module Errors
4
+ class CommandError < Heroku::Commander::Errors::Base
5
+
6
+ attr_accessor :inner_exception
7
+
8
+ def initialize(opts = {})
9
+ @inner_exception = opts[:inner_exception]
10
+ super(compose_message("command_error", prepare_lines(opts)))
11
+ end
12
+
13
+ private
14
+
15
+ def prepare_lines(opts)
16
+ if opts[:lines] && opts[:lines].size > 4
17
+ lines = opts[:lines][0..2]
18
+ lines.push "... skipping #{opts[:lines].size - 4} line(s) ..."
19
+ lines.concat opts[:lines][-2..-1]
20
+ result = opts.dup
21
+ result[:lines] = "\n\t" + lines.join("\n\t")
22
+ result
23
+ elsif opts[:lines]
24
+ result = opts.dup
25
+ result[:lines] = "\n\t" + result[:lines].join("\n\t")
26
+ result
27
+ else
28
+ opts
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,13 @@
1
+ module Heroku
2
+ class Commander
3
+ module Errors
4
+ class MissingCommandError < Heroku::Commander::Errors::Base
5
+
6
+ def initialize
7
+ super(compose_message("missing_command_error"))
8
+ end
9
+
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,14 @@
1
+ module Heroku
2
+ class Commander
3
+ module Errors
4
+ class UnexpectedOutputError < Heroku::Commander::Errors::Base
5
+
6
+ attr_accessor :inner_exception
7
+
8
+ def initialize(opts)
9
+ super(compose_message("unexpected_output", opts))
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,5 @@
1
+ module Heroku
2
+ class Commander
3
+ VERSION = '0.1.0'
4
+ end
5
+ end
@@ -0,0 +1,33 @@
1
+ module Heroku
2
+ class Config < Hash
3
+
4
+ attr_accessor :app, :logger
5
+
6
+ def initialize(options = {})
7
+ @app = options[:app]
8
+ @logger = options[:logger]
9
+ end
10
+
11
+ def reload!
12
+ clear
13
+ cmd = cmdline
14
+ Heroku::Executor.run cmd, { :logger => logger } do |line|
15
+ logger.debug "< #{line}" if logger
16
+ parts = line.split "=", 2
17
+ raise Heroku::Commander::Errors::UnexpectedOutputError.new({
18
+ :cmd => cmd,
19
+ :line => line
20
+ }) if parts.size != 2
21
+ self[parts[0].strip] = parts[1].strip
22
+ end
23
+ self
24
+ end
25
+
26
+ protected
27
+
28
+ def cmdline
29
+ [ "heroku", "config", "-s", @app ? "--app #{@app}" : nil ].compact.join(" ")
30
+ end
31
+
32
+ end
33
+ end
@@ -0,0 +1,82 @@
1
+ module Heroku
2
+ class Executor
3
+
4
+ class Terminate < StandardError
5
+ end
6
+
7
+ class << self
8
+
9
+ # Executes a command and yields output line-by-line.
10
+ def run(cmd, options = {}, &block)
11
+ lines = []
12
+ logger = options[:logger]
13
+ logger.debug "Running: #{cmd}" if logger
14
+ PTY.spawn(cmd) do |r, w, pid|
15
+ logger.debug "Started: #{pid}" if logger
16
+ terminated = false
17
+ begin
18
+ r.sync = true
19
+ r.each do |line|
20
+ line.strip! if line
21
+ logger.debug "#{pid}: #{line}" if logger
22
+ if block_given?
23
+ yield line
24
+ end
25
+ lines << line
26
+ end
27
+ rescue Heroku::Executor::Terminate
28
+ logger.debug "Terminating #{pid}." if logger
29
+ Process.kill("TERM", pid)
30
+ terminated = true
31
+ rescue Errno::EIO, IOError => e
32
+ logger.debug "#{e.class}: #{e.message}" if logger
33
+ rescue PTY::ChildExited => e
34
+ logger.debug "Terminated: #{pid}" if logger
35
+ terminted = true
36
+ raise e
37
+ ensure
38
+ unless terminated
39
+ logger.debug "Waiting: #{pid}" if logger
40
+ Process.wait(pid) rescue Errno::ECHILD
41
+ end
42
+ end
43
+ end
44
+ check_exit_status! cmd, $?.exitstatus, lines
45
+ lines
46
+ rescue Errno::ECHILD => e
47
+ logger.debug "#{e.class}: #{e.message}" if logger
48
+ check_exit_status! cmd, $?.exitstatus, lines
49
+ lines
50
+ rescue PTY::ChildExited => e
51
+ logger.debug "#{e.class}: #{e.message}" if logger
52
+ check_exit_status! cmd, $!.status.exitstatus, lines
53
+ lines
54
+ rescue Heroku::Commander::Errors::Base => e
55
+ logger.debug "Error: #{e.problem}" if logger
56
+ raise
57
+ rescue Exception => e
58
+ logger.debug "#{e.class}: #{e.respond_to?(:problem) ? e.problem : e.message}" if logger
59
+ raise Heroku::Commander::Errors::CommandError.new({
60
+ :cmd => cmd,
61
+ :status => $?.exitstatus,
62
+ :message => e.message,
63
+ :inner_exception => e,
64
+ :lines => lines
65
+ })
66
+ end
67
+
68
+ private
69
+
70
+ def check_exit_status!(cmd, status, lines = nil)
71
+ return if ! status || status == 0
72
+ raise Heroku::Commander::Errors::CommandError.new({
73
+ :cmd => cmd,
74
+ :status => status,
75
+ :message => "The command #{cmd} failed with exit status #{status}.",
76
+ :lines => lines
77
+ })
78
+ end
79
+
80
+ end
81
+ end
82
+ end
data/lib/heroku/pty.rb ADDED
@@ -0,0 +1,7 @@
1
+ if ! defined? PTY::ChildExited
2
+ module PTY
3
+ class ChildExited < StandardError
4
+ # missing on JRuby
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,114 @@
1
+ module Heroku
2
+ class Runner
3
+
4
+ attr_accessor :app, :logger, :command
5
+ attr_reader :pid, :running, :tail
6
+
7
+ def initialize(options = {})
8
+ @app = options[:app]
9
+ @logger = options[:logger]
10
+ @command = options[:command]
11
+ raise Heroku::Commander::Errors::MissingCommandError unless @command
12
+ end
13
+
14
+ def running?
15
+ !! @running
16
+ end
17
+
18
+ def run!(options = {}, &block)
19
+ if options && options[:detached]
20
+ run_detached! &block
21
+ else
22
+ run_attached! &block
23
+ end
24
+ end
25
+
26
+ protected
27
+
28
+ def run_attached!(&block)
29
+ @pid = nil
30
+ previous_line = nil # delay by 1 to avoid rc=status line
31
+ lines = Heroku::Executor.run cmdline, { :logger => logger } do |line|
32
+ if ! @pid
33
+ check_pid(line)
34
+ elsif block_given?
35
+ yield previous_line if previous_line
36
+ previous_line = line
37
+ end
38
+ end
39
+ lines.shift # remove Running `...` attached to terminal... up, run.xyz
40
+ check_exit_status! lines
41
+ lines
42
+ end
43
+
44
+ def run_detached!(&block)
45
+ raise Heroku::Commander::Errors::AlreadyRunningError.new({ :pid => @pid }) if running?
46
+ @running = true
47
+ @pid = nil
48
+ @tail = nil
49
+ lines = Heroku::Executor.run cmdline({ :detached => true }), { :logger => logger } do |line|
50
+ check_pid(line) unless @pid
51
+ @tail ||= tail!(&block) if @pid
52
+ end
53
+ check_exit_status! @tail || lines
54
+ @running = false
55
+ @tail || lines
56
+ end
57
+
58
+ def cmdline(options = {})
59
+ [ "heroku", options[:detached] ? "run:detached" : "run", "\"(#{command} 2>&1 ; echo rc=\\$?)\"", @app ? "--app #{@app}" : nil ].compact.join(" ")
60
+ end
61
+
62
+ def check_exit_status!(lines)
63
+ status = (lines.size > 0) && (match = lines[-1].match(/^rc=(\d+)$/)) ? match[1] : nil
64
+ lines.pop if status
65
+ raise Heroku::Commander::Errors::CommandError.new({
66
+ :cmd => @command,
67
+ :status => status,
68
+ :message => "The command #{@command} failed with exit status #{status}.",
69
+ :lines => lines
70
+ }) unless status && status == "0"
71
+ end
72
+
73
+ def check_pid(line)
74
+ if (match = line.match /attached to terminal... up, (run.\d+)$/)
75
+ @pid = match[1]
76
+ logger.debug "Heroku pid #{@pid} up." if logger
77
+ elsif (match = line.match /detached... up, (run.\d+)$/)
78
+ @pid = match[1]
79
+ logger.debug "Heroku detached pid #{@pid} up." if logger
80
+ else
81
+ @pid = ''
82
+ end
83
+ end
84
+
85
+ def tail!(&block)
86
+ lines = []
87
+ tail_cmdline = [ "heroku", "logs", "-p #{@pid}", "--tail", @app ? "--app #{@app}" : nil ].compact.join(" ")
88
+ previous_line = nil # delay by 1 to avoid rc=status line
89
+ Heroku::Executor.run tail_cmdline, { :logger => logger } do |line|
90
+ # remove any ANSI output
91
+ line = line.gsub /\e\[(\d+)m/, ''
92
+ # lines are returned as [date/time] app/heroku[pid]: output
93
+ line = line.split("[#{@pid}]:")[-1].strip
94
+ if line.match(/Starting process with command/) || line.match(/State changed from \w+ to up/)
95
+ # ignore
96
+ elsif line.match(/State changed from \w+ to complete/) || line.match(/Process exited with status \d+/)
97
+ terminate_executor!
98
+ else
99
+ if block_given?
100
+ yield previous_line if previous_line
101
+ previous_line = line
102
+ end
103
+ lines << line
104
+ end
105
+ end
106
+ lines
107
+ end
108
+
109
+ def terminate_executor!
110
+ raise Heroku::Executor::Terminate
111
+ end
112
+
113
+ end
114
+ end
@@ -0,0 +1,2 @@
1
+ require 'heroku-commander'
2
+
@@ -0,0 +1,81 @@
1
+ require 'spec_helper'
2
+
3
+ describe Heroku::Commander do
4
+ context "without arguments" do
5
+ subject do
6
+ Heroku::Commander.new
7
+ end
8
+ its(:app) { should be_nil }
9
+ end
10
+ context "with app" do
11
+ before :each do
12
+ Heroku::Executor.stub(:run)
13
+ end
14
+ subject do
15
+ Heroku::Commander.new({ :app => "heroku-commander" })
16
+ end
17
+ its(:app) { should eq "heroku-commander" }
18
+ its(:config) { should be_a Heroku::Config }
19
+ end
20
+ context "with a heroku configuration" do
21
+ before :each do
22
+ Heroku::Executor.stub(:run).with("heroku config -s --app heroku-commander", { :logger => nil }).
23
+ and_yield("APP_NAME=heroku-commander").
24
+ and_yield("RACK_ENV=staging")
25
+ end
26
+ subject { Heroku::Commander.new({ :app => "heroku-commander" }).config }
27
+ context "config" do
28
+ its(:size) { should == 2 }
29
+ it { subject["APP_NAME"].should eq "heroku-commander" }
30
+ end
31
+ end
32
+ context "with logger" do
33
+ subject do
34
+ logger = Logger.new($stdout)
35
+ Heroku::Commander.new({ :logger => logger })
36
+ end
37
+ context "reload!" do
38
+ it "passes the logger" do
39
+ PTY.stub(:spawn)
40
+ subject.logger.should_receive(:debug).with("Running: heroku config -s")
41
+ subject.config
42
+ end
43
+ end
44
+ end
45
+ context "run" do
46
+ it "runs the command" do
47
+ Heroku::Executor.stub(:run).
48
+ and_yield("Running `...` attached to terminal... up, run.1234").
49
+ and_yield("app").
50
+ and_yield("bin").
51
+ and_yield("rc=0").
52
+ and_return([ "Running `...` attached to terminal... up, run.1234", "app", "bin", "rc=0" ])
53
+ subject.run("ls -1").should == [ "app", "bin" ]
54
+ end
55
+ it "runs the command detached" do
56
+ Heroku::Executor.stub(:run).with("heroku run:detached \"(ls -1 2>&1 ; echo rc=\\$?)\"", { :logger => nil }).
57
+ and_yield("Running `ls -1` detached... up, run.8748").
58
+ and_yield("Use `heroku logs -p run.8748` to view the output.").
59
+ and_yield("rc=0").
60
+ and_return([ "Running `ls -1` detached... up, run.8748", "Use `heroku logs -p run.8748` to view the output.", "rc=0" ])
61
+ Heroku::Executor.stub(:run).with("heroku logs -p run.8748 --tail", { :logger => nil }).
62
+ and_yield("2013-01-31T01:39:30+00:00 heroku[run.8748]: Starting process with command `ls -1`").
63
+ and_yield("2013-01-31T01:39:31+00:00 app[run.8748]: bin").
64
+ and_yield("2013-01-31T01:39:31+00:00 app[run.8748]: app").
65
+ and_yield("2013-01-31T00:56:13+00:00 app[run.8748]: rc=0").
66
+ and_yield("2013-01-31T01:39:33+00:00 heroku[run.8748]: Process exited with status 0").
67
+ and_yield("2013-01-31T01:39:33+00:00 heroku[run.8748]: State changed from up to complete").
68
+ and_return([
69
+ "2013-01-31T01:39:30+00:00 heroku[run.8748]: Starting process with command `ls -1`",
70
+ "2013-01-31T01:39:31+00:00 app[run.8748]: bin",
71
+ "2013-01-31T01:39:31+00:00 app[run.8748]: app",
72
+ "2013-01-31T00:56:13+00:00 app[run.8748]: rc=0",
73
+ "2013-01-31T01:39:33+00:00 heroku[run.8748]: Process exited with status 0",
74
+ "2013-01-31T01:39:33+00:00 heroku[run.8748]: State changed from up to complete"
75
+ ])
76
+ Heroku::Runner.any_instance.should_receive(:terminate_executor!).twice
77
+ subject.run("ls -1", { :detached => true }).should == [ "bin", "app" ]
78
+ end
79
+ end
80
+ end
81
+
@@ -0,0 +1,65 @@
1
+ require 'spec_helper'
2
+
3
+ describe Heroku::Config do
4
+ context "without arguments" do
5
+ subject do
6
+ Heroku::Config.new
7
+ end
8
+ its(:app) { should be_nil }
9
+ its(:cmdline) { should eq "heroku config -s" }
10
+ context "reload!" do
11
+ it "reloads the configuration" do
12
+ Heroku::Executor.stub(:run).with("heroku config -s", { :logger => nil }).
13
+ and_yield("APP_NAME=heroku-commander").
14
+ and_yield("RACK_ENV=staging")
15
+ subject.reload!
16
+ subject.size.should == 2
17
+ subject["APP_NAME"].should eq "heroku-commander"
18
+ subject["RACK_ENV"].should eq "staging"
19
+ end
20
+ it "reloads the configuration a second time" do
21
+ subject["APP_NAME"] = "old"
22
+ subject["OLD_VARIABLE"] = "old"
23
+ Heroku::Executor.stub(:run).with("heroku config -s", { :logger => nil }).
24
+ and_yield("APP_NAME=heroku-commander").
25
+ and_yield("RACK_ENV=staging")
26
+ subject.reload!
27
+ subject.size.should == 2
28
+ subject["APP_NAME"].should eq "heroku-commander"
29
+ subject["RACK_ENV"].should eq "staging"
30
+ end
31
+ end
32
+ end
33
+ context "with app" do
34
+ subject do
35
+ Heroku::Config.new({ :app => "heroku-commander" })
36
+ end
37
+ its(:app) { should eq "heroku-commander" }
38
+ its(:cmdline) { should eq "heroku config -s --app heroku-commander" }
39
+ context "reload!" do
40
+ it "reloads the configuration" do
41
+ Heroku::Executor.stub(:run).with("heroku config -s --app heroku-commander", { :logger => nil }).
42
+ and_yield("APP_NAME=heroku-commander").
43
+ and_yield("RACK_ENV=staging")
44
+ subject.reload!
45
+ subject.size.should == 2
46
+ subject["APP_NAME"].should eq "heroku-commander"
47
+ subject["RACK_ENV"].should eq "staging"
48
+ end
49
+ end
50
+ end
51
+ context "with logger" do
52
+ subject do
53
+ logger = Logger.new($stdout)
54
+ Heroku::Config.new({ :logger => logger })
55
+ end
56
+ context "reload!" do
57
+ it "passes the logger" do
58
+ PTY.stub(:spawn)
59
+ subject.logger.should_receive(:debug).with("Running: heroku config -s")
60
+ subject.reload!
61
+ end
62
+ end
63
+ end
64
+ end
65
+
@@ -0,0 +1,42 @@
1
+ require 'spec_helper'
2
+
3
+ describe Heroku::Executor do
4
+ context "missing argument" do
5
+ subject { lambda { Heroku::Executor.run } }
6
+ it { should raise_error ArgumentError }
7
+ end
8
+ context "command does not exist" do
9
+ subject { lambda { Heroku::Executor.run "executor_spec.rb" } }
10
+ it { should raise_error Heroku::Commander::Errors::CommandError, /The command `executor_spec.rb` failed with exit status \d+./ }
11
+ end
12
+ context "command exists" do
13
+ subject { lambda { Heroku::Executor.run "ls -1" } }
14
+ it { should_not raise_error }
15
+ its(:call) { should include "Gemfile" }
16
+ end
17
+ context "line-by-line" do
18
+ it "yields" do
19
+ lines = []
20
+ Heroku::Executor.run "ls -1" do |line|
21
+ lines << line
22
+ end
23
+ lines.should include "Gemfile"
24
+ end
25
+ it "doesn't yield nil lines" do
26
+ r = double(IO)
27
+ r.stub(:sync=)
28
+ r.stub(:each).and_yield("line1").and_yield(nil).and_yield("rc=0")
29
+ Process.stub(:wait)
30
+ PTY.stub(:spawn).and_yield(r, nil, 42)
31
+ Heroku::Executor.run("foobar").should == [ "line1", nil, "rc=0" ]
32
+ end
33
+ end
34
+ context "logger" do
35
+ it "logs command" do
36
+ logger = Logger.new($stdout)
37
+ logger.should_receive(:debug).at_least(2).times
38
+ Heroku::Executor.run "ls -1", { :logger => logger }
39
+ end
40
+ end
41
+ end
42
+
@@ -0,0 +1,92 @@
1
+ require 'spec_helper'
2
+
3
+ describe Heroku::Runner do
4
+ context "without a command" do
5
+ it "raises a missing command error" do
6
+ expect {
7
+ Heroku::Runner.new
8
+ }.to raise_error Heroku::Commander::Errors::MissingCommandError, /Missing command./
9
+ end
10
+ end
11
+ context "with a command" do
12
+ subject do
13
+ Heroku::Runner.new({ :command => "ls -1" })
14
+ end
15
+ its(:app) { should be_nil }
16
+ its(:logger) { should be_nil }
17
+ its(:command) { should eq "ls -1" }
18
+ its(:cmdline) { should eq "heroku run \"(ls -1 2>&1 ; echo rc=\\$?)\"" }
19
+ its(:running?) { should be_false }
20
+ context "run!" do
21
+ before :each do
22
+ Heroku::Executor.stub(:run).with(subject.send(:cmdline), { :logger => nil }).
23
+ and_yield("Running `...` attached to terminal... up, run.9783").
24
+ and_yield("app").
25
+ and_yield("bin").
26
+ and_yield("rc= 0").
27
+ and_return([ "Running `...` attached to terminal... up, run.9783", "app", "bin", "rc=0" ])
28
+ end
29
+ it "runs the command w/o a block" do
30
+ subject.run!.should == [ "app", "bin" ]
31
+ subject.pid.should == "run.9783"
32
+ subject.should_not be_running
33
+ end
34
+ it "runs the command with a block" do
35
+ lines = []
36
+ subject.run!.each do |line|
37
+ lines << line
38
+ end
39
+ lines.should == [ "app", "bin" ]
40
+ subject.pid.should == "run.9783"
41
+ subject.should_not be_running
42
+ end
43
+ it "raises an exception if the command fails" do
44
+ Heroku::Executor.stub(:run).with(subject.send(:cmdline), { :logger => nil }).
45
+ and_return([ "Running `...` attached to terminal... up, run.9783", "app", "bin", "rc=1" ])
46
+ expect {
47
+ subject.run!
48
+ }.to raise_error Heroku::Commander::Errors::CommandError, /The command `ls -1` failed with exit status 1./
49
+ end
50
+ end
51
+ context "run! detached" do
52
+ before :each do
53
+ Heroku::Executor.stub(:run).with(subject.send(:cmdline, { :detached => true }), { :logger => nil }).
54
+ and_yield("Running `ls -1` detached... up, run.8748").
55
+ and_yield("Use `heroku logs -p run.8748` to view the output.").
56
+ and_yield("rc=0").
57
+ and_return([ "Running `ls -1` detached... up, run.8748", "Use `heroku logs -p run.8748` to view the output.", "rc=0" ])
58
+ Heroku::Executor.stub(:run).with("heroku logs -p run.8748 --tail", { :logger => nil }).
59
+ and_yield("2013-01-31T01:39:30+00:00 heroku[run.8748]: Starting process with command `ls -1`").
60
+ and_yield("2013-01-31T01:39:31+00:00 app[run.8748]: bin").
61
+ and_yield("2013-01-31T01:39:31+00:00 app[run.8748]: app").
62
+ and_yield("2013-01-31T00:56:13+00:00 app[run.8748]: rc=0").
63
+ and_yield("2013-01-31T01:39:33+00:00 heroku[run.8748]: Process exited with status 0").
64
+ and_yield("2013-01-31T01:39:33+00:00 heroku[run.8748]: State changed from up to complete").
65
+ and_return([
66
+ "2013-01-31T01:39:30+00:00 heroku[run.8748]: Starting process with command `ls -1`",
67
+ "2013-01-31T01:39:31+00:00 app[run.8748]: bin",
68
+ "2013-01-31T01:39:31+00:00 app[run.8748]: app",
69
+ "2013-01-31T00:56:13+00:00 app[run.8748]: rc=0",
70
+ "2013-01-31T01:39:33+00:00 heroku[run.8748]: Process exited with status 0",
71
+ "2013-01-31T01:39:33+00:00 heroku[run.8748]: State changed from up to complete"
72
+ ])
73
+ Heroku::Runner.any_instance.should_receive(:terminate_executor!).twice
74
+ end
75
+ it "runs the command w/o a block" do
76
+ subject.run!({ :detached => true }).should == [ "bin", "app" ]
77
+ subject.pid.should == "run.8748"
78
+ subject.should_not be_running
79
+ end
80
+ it "runs the command with a block" do
81
+ lines = []
82
+ subject.run!({ :detached => true }).each do |line|
83
+ lines << line
84
+ end
85
+ lines.should == [ "bin", "app" ]
86
+ subject.pid.should == "run.8748"
87
+ subject.should_not be_running
88
+ end
89
+ end
90
+ end
91
+ end
92
+
@@ -0,0 +1,8 @@
1
+ require 'spec_helper'
2
+
3
+ describe Heroku::Commander do
4
+ it "has a version" do
5
+ Heroku::Commander::VERSION.should_not be_nil
6
+ end
7
+ end
8
+
@@ -0,0 +1,6 @@
1
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
2
+ $LOAD_PATH.unshift(File.dirname(__FILE__))
3
+
4
+ require 'rubygems'
5
+ require 'rspec'
6
+ require 'heroku-commander'
metadata ADDED
@@ -0,0 +1,100 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: heroku-commander
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Daniel Doubrovkine
9
+ - Frank Macreery
10
+ autorequire:
11
+ bindir: bin
12
+ cert_chain: []
13
+ date: 2013-02-02 00:00:00.000000000 Z
14
+ dependencies:
15
+ - !ruby/object:Gem::Dependency
16
+ name: i18n
17
+ requirement: !ruby/object:Gem::Requirement
18
+ none: false
19
+ requirements:
20
+ - - ! '>='
21
+ - !ruby/object:Gem::Version
22
+ version: '0'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ none: false
27
+ requirements:
28
+ - - ! '>='
29
+ - !ruby/object:Gem::Version
30
+ version: '0'
31
+ description:
32
+ email: dblock@dblock.org
33
+ executables: []
34
+ extensions: []
35
+ extra_rdoc_files: []
36
+ files:
37
+ - .gitignore
38
+ - .rspec
39
+ - .travis.yml
40
+ - CHANGELOG.md
41
+ - Gemfile
42
+ - LICENSE.md
43
+ - README.md
44
+ - Rakefile
45
+ - assets/heroku-commander.png
46
+ - examples/heroku-config.rb
47
+ - examples/heroku-run-detached.rb
48
+ - examples/heroku-run.rb
49
+ - heroku-commander.gemspec
50
+ - lib/config/locales/en.yml
51
+ - lib/heroku-commander.rb
52
+ - lib/heroku/commander.rb
53
+ - lib/heroku/commander/errors.rb
54
+ - lib/heroku/commander/errors/already_running_error.rb
55
+ - lib/heroku/commander/errors/base.rb
56
+ - lib/heroku/commander/errors/client_eio_error.rb
57
+ - lib/heroku/commander/errors/command_error.rb
58
+ - lib/heroku/commander/errors/missing_command_error.rb
59
+ - lib/heroku/commander/errors/unexpected_output_error.rb
60
+ - lib/heroku/commander/version.rb
61
+ - lib/heroku/config.rb
62
+ - lib/heroku/executor.rb
63
+ - lib/heroku/pty.rb
64
+ - lib/heroku/runner.rb
65
+ - lib/heroku_commander.rb
66
+ - spec/heroku/commander_spec.rb
67
+ - spec/heroku/config_spec.rb
68
+ - spec/heroku/executor_spec.rb
69
+ - spec/heroku/runner_spec.rb
70
+ - spec/heroku/version_spec.rb
71
+ - spec/spec_helper.rb
72
+ homepage: http://github.com/dblock/heroku-commander
73
+ licenses:
74
+ - MIT
75
+ post_install_message:
76
+ rdoc_options: []
77
+ require_paths:
78
+ - lib
79
+ required_ruby_version: !ruby/object:Gem::Requirement
80
+ none: false
81
+ requirements:
82
+ - - ! '>='
83
+ - !ruby/object:Gem::Version
84
+ version: '0'
85
+ segments:
86
+ - 0
87
+ hash: 155166833
88
+ required_rubygems_version: !ruby/object:Gem::Requirement
89
+ none: false
90
+ requirements:
91
+ - - ! '>='
92
+ - !ruby/object:Gem::Version
93
+ version: 1.3.6
94
+ requirements: []
95
+ rubyforge_project:
96
+ rubygems_version: 1.8.24
97
+ signing_key:
98
+ specification_version: 3
99
+ summary: Control Heroku from Ruby via its `heroku` shell command.
100
+ test_files: []