heroku-commander 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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: []