herokugarden 0.4.2
Sign up to get free protection for your applications and to get access to all the features.
- data/Rakefile +101 -0
- data/bin/herokugarden +51 -0
- data/lib/herokugarden.rb +5 -0
- data/lib/herokugarden/client.rb +153 -0
- data/lib/herokugarden/command_line.rb +450 -0
- data/spec/base.rb +13 -0
- data/spec/client_spec.rb +157 -0
- data/spec/command_line_spec.rb +334 -0
- metadata +77 -0
data/Rakefile
ADDED
@@ -0,0 +1,101 @@
|
|
1
|
+
require 'rake'
|
2
|
+
require 'spec/rake/spectask'
|
3
|
+
|
4
|
+
desc "Run all specs"
|
5
|
+
Spec::Rake::SpecTask.new('spec') do |t|
|
6
|
+
t.spec_opts = ['--colour --format progress --loadby mtime --reverse']
|
7
|
+
t.spec_files = FileList['spec/*_spec.rb']
|
8
|
+
end
|
9
|
+
|
10
|
+
desc "Print specdocs"
|
11
|
+
Spec::Rake::SpecTask.new(:doc) do |t|
|
12
|
+
t.spec_opts = ["--format", "specdoc", "--dry-run"]
|
13
|
+
t.spec_files = FileList['spec/*_spec.rb']
|
14
|
+
end
|
15
|
+
|
16
|
+
desc "Generate RCov code coverage report"
|
17
|
+
Spec::Rake::SpecTask.new('rcov') do |t|
|
18
|
+
t.spec_files = FileList['spec/*_spec.rb']
|
19
|
+
t.rcov = true
|
20
|
+
t.rcov_opts = ['--exclude', 'examples']
|
21
|
+
end
|
22
|
+
|
23
|
+
task :default => :spec
|
24
|
+
|
25
|
+
######################################################
|
26
|
+
|
27
|
+
require 'rake'
|
28
|
+
require 'rake/testtask'
|
29
|
+
require 'rake/clean'
|
30
|
+
require 'rake/gempackagetask'
|
31
|
+
require 'rake/rdoctask'
|
32
|
+
require 'fileutils'
|
33
|
+
include FileUtils
|
34
|
+
require 'lib/herokugarden'
|
35
|
+
|
36
|
+
version = HerokuGarden::Client.version
|
37
|
+
name = "herokugarden"
|
38
|
+
|
39
|
+
spec = Gem::Specification.new do |s|
|
40
|
+
s.name = name
|
41
|
+
s.version = version
|
42
|
+
s.summary = "Client library and CLI to deploy Rails apps on Heroku Garden."
|
43
|
+
s.description = "Client library and command-line tool to manage and deploy Rails apps on Heroku Garden."
|
44
|
+
s.author = "Adam Wiggins"
|
45
|
+
s.email = "feedback@heroku.com"
|
46
|
+
s.homepage = "http://herokugarden.com/"
|
47
|
+
s.executables = [ "herokugarden" ]
|
48
|
+
s.default_executable = "herokugarden"
|
49
|
+
s.rubyforge_project = "herokugarden"
|
50
|
+
s.post_install_message = <<EOF
|
51
|
+
|
52
|
+
---> Installing herokugarden v#{version}
|
53
|
+
---> Migrate local checkouts using the git:transition command:
|
54
|
+
|
55
|
+
cd myapp/
|
56
|
+
herokugarden git:transition
|
57
|
+
|
58
|
+
EOF
|
59
|
+
|
60
|
+
s.platform = Gem::Platform::RUBY
|
61
|
+
s.has_rdoc = true
|
62
|
+
|
63
|
+
s.files = %w(Rakefile) +
|
64
|
+
Dir.glob("{bin,lib,spec}/**/*")
|
65
|
+
|
66
|
+
s.require_path = "lib"
|
67
|
+
s.bindir = "bin"
|
68
|
+
|
69
|
+
s.add_dependency('rest-client', '>=0.5')
|
70
|
+
end
|
71
|
+
|
72
|
+
Rake::GemPackageTask.new(spec) do |p|
|
73
|
+
p.need_tar = true if RUBY_PLATFORM !~ /mswin/
|
74
|
+
end
|
75
|
+
|
76
|
+
task :install => [ :test, :package ] do
|
77
|
+
sh %{sudo gem install pkg/#{name}-#{version}.gem}
|
78
|
+
end
|
79
|
+
|
80
|
+
task :uninstall => [ :clean ] do
|
81
|
+
sh %{sudo gem uninstall #{name}}
|
82
|
+
end
|
83
|
+
|
84
|
+
Rake::TestTask.new do |t|
|
85
|
+
t.libs << "spec"
|
86
|
+
t.test_files = FileList['spec/*_spec.rb']
|
87
|
+
t.verbose = true
|
88
|
+
end
|
89
|
+
|
90
|
+
Rake::RDocTask.new do |t|
|
91
|
+
t.rdoc_dir = 'rdoc'
|
92
|
+
t.title = "Heroku API"
|
93
|
+
t.options << '--line-numbers' << '--inline-source' << '-A cattr_accessor=object'
|
94
|
+
t.options << '--charset' << 'utf-8'
|
95
|
+
t.rdoc_files.include('README')
|
96
|
+
t.rdoc_files.include('REST')
|
97
|
+
t.rdoc_files.include('lib/heroku/*.rb')
|
98
|
+
end
|
99
|
+
|
100
|
+
CLEAN.include [ 'build/*', '**/*.o', '**/*.so', '**/*.a', 'lib/*-*', '**/*.log', 'pkg', 'lib/*.bundle', '*.gem', '.config' ]
|
101
|
+
|
data/bin/herokugarden
ADDED
@@ -0,0 +1,51 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
$LOAD_PATH.unshift(File.dirname(__FILE__) + '/../lib')
|
4
|
+
|
5
|
+
require 'herokugarden'
|
6
|
+
require 'herokugarden/command_line'
|
7
|
+
|
8
|
+
usage = <<EOTXT
|
9
|
+
=== Heroku Garden Commands
|
10
|
+
list - list your apps
|
11
|
+
create [<name>] - create a new app
|
12
|
+
info <app> - show app info, like web url and git repo
|
13
|
+
update <app> - update the app
|
14
|
+
--name <newname>
|
15
|
+
--public (true|false)
|
16
|
+
--mode (production|development)
|
17
|
+
sharing <app> - manage collaborators
|
18
|
+
--add <email>
|
19
|
+
--remove <email>
|
20
|
+
--access (edit|view)
|
21
|
+
rake <app> <command> - remotely execute a rake command
|
22
|
+
destroy <app> - destroy the app permanently
|
23
|
+
keys - manage your user's ssh public keys for git access
|
24
|
+
--add [<path to keyfile>]
|
25
|
+
--remove <keyname or all>
|
26
|
+
|
27
|
+
Example story:
|
28
|
+
herokugarden create myapp
|
29
|
+
git clone git@herokugarden.com:myapp.git
|
30
|
+
cd myapp
|
31
|
+
(...make edits...)
|
32
|
+
git add .
|
33
|
+
git commit -m "changes"
|
34
|
+
git push
|
35
|
+
herokugarden update myapp --public true --mode production
|
36
|
+
EOTXT
|
37
|
+
|
38
|
+
command = ARGV.shift.strip.gsub(/:/, '_') rescue ""
|
39
|
+
if command.length == 0
|
40
|
+
puts usage
|
41
|
+
exit 1
|
42
|
+
end
|
43
|
+
|
44
|
+
cli = HerokuGarden::CommandLine.new
|
45
|
+
unless cli.methods.include? command
|
46
|
+
puts "no such method as #{command}, run without arguments for usage"
|
47
|
+
exit 2
|
48
|
+
end
|
49
|
+
|
50
|
+
cli.execute(command, ARGV)
|
51
|
+
|
data/lib/herokugarden.rb
ADDED
@@ -0,0 +1,153 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'rexml/document'
|
3
|
+
require 'rest_client'
|
4
|
+
require 'uri'
|
5
|
+
|
6
|
+
# A Ruby class to call the Heroku REST API. You might use this if you want to
|
7
|
+
# manage your Heroku apps from within a Ruby program, such as Capistrano.
|
8
|
+
#
|
9
|
+
# Example:
|
10
|
+
#
|
11
|
+
# require 'heroku'
|
12
|
+
# heroku = HerokuGarden::Client.new('me@example.com', 'mypass')
|
13
|
+
# heroku.create('myapp')
|
14
|
+
#
|
15
|
+
class HerokuGarden::Client
|
16
|
+
def self.version
|
17
|
+
'0.4.2'
|
18
|
+
end
|
19
|
+
|
20
|
+
attr_accessor :host, :user, :password
|
21
|
+
|
22
|
+
def initialize(user, password, host='herokugarden.com')
|
23
|
+
@user = user
|
24
|
+
@password = password
|
25
|
+
@host = host
|
26
|
+
end
|
27
|
+
|
28
|
+
# Show a list of apps which you are a collaborator on.
|
29
|
+
def list
|
30
|
+
doc = xml(get('/apps'))
|
31
|
+
doc.elements.to_a("//apps/app/name").map { |a| a.text }
|
32
|
+
end
|
33
|
+
|
34
|
+
# Show info such as mode, custom domain, and collaborators on an app.
|
35
|
+
def info(name, saferoute=false)
|
36
|
+
doc = xml(get("/#{'safe' if saferoute}apps/#{name}"))
|
37
|
+
attrs = { :collaborators => list_collaborators(name) }
|
38
|
+
doc.elements.to_a('//app/*').inject(attrs) do |hash, element|
|
39
|
+
hash[element.name.to_sym] = element.text; hash
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
# Create a new app, with an optional name.
|
44
|
+
def create(name=nil, options={})
|
45
|
+
params = {}
|
46
|
+
params['app[name]'] = name if name
|
47
|
+
xml(post('/apps', params)).elements["//app/name"].text
|
48
|
+
end
|
49
|
+
|
50
|
+
# Update an app. Available attributes:
|
51
|
+
# :name => rename the app (changes http and git urls)
|
52
|
+
# :public => true | false
|
53
|
+
# :mode => production | development
|
54
|
+
def update(name, attributes)
|
55
|
+
put("/apps/#{name}", :app => attributes)
|
56
|
+
end
|
57
|
+
|
58
|
+
# Destroy the app permanently.
|
59
|
+
def destroy(name)
|
60
|
+
delete("/apps/#{name}")
|
61
|
+
end
|
62
|
+
|
63
|
+
# Get a list of collaborators on the app, returns an array of hashes each of
|
64
|
+
# which contain :email and :access (=> edit | view) elements.
|
65
|
+
def list_collaborators(app_name)
|
66
|
+
doc = xml(get("/apps/#{app_name}/collaborators"))
|
67
|
+
doc.elements.to_a("//collaborators/collaborator").map do |a|
|
68
|
+
{ :email => a.elements['email'].text, :access => a.elements['access'].text }
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
# Invite a person by email address to collaborate on the app. Optional
|
73
|
+
# third parameter can be edit or view.
|
74
|
+
def add_collaborator(app_name, email, access='view')
|
75
|
+
xml(post("/apps/#{app_name}/collaborators", { 'collaborator[email]' => email, 'collaborator[access]' => access }))
|
76
|
+
end
|
77
|
+
|
78
|
+
# Change an existing collaborator.
|
79
|
+
def update_collaborator(app_name, email, access)
|
80
|
+
put("/apps/#{app_name}/collaborators/#{escape(email)}", { 'collaborator[access]' => access })
|
81
|
+
end
|
82
|
+
|
83
|
+
# Remove a collaborator.
|
84
|
+
def remove_collaborator(app_name, email)
|
85
|
+
delete("/apps/#{app_name}/collaborators/#{escape(email)}")
|
86
|
+
end
|
87
|
+
|
88
|
+
# Get the list of ssh public keys for the current user.
|
89
|
+
def keys
|
90
|
+
doc = xml get('/user/keys')
|
91
|
+
doc.elements.to_a('//keys/key').map do |key|
|
92
|
+
key.elements['contents'].text
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
# Add an ssh public key to the current user.
|
97
|
+
def add_key(key)
|
98
|
+
post("/user/keys", key, { 'Content-Type' => 'text/ssh-authkey' })
|
99
|
+
end
|
100
|
+
|
101
|
+
# Remove an existing ssh public key from the current user.
|
102
|
+
def remove_key(key)
|
103
|
+
delete("/user/keys/#{escape(key)}")
|
104
|
+
end
|
105
|
+
|
106
|
+
# Clear all keys on the current user.
|
107
|
+
def remove_all_keys
|
108
|
+
delete("/user/keys")
|
109
|
+
end
|
110
|
+
|
111
|
+
# Run a rake command on the Heroku app.
|
112
|
+
def rake(app_name, cmd)
|
113
|
+
post("/apps/#{app_name}/rake", cmd)
|
114
|
+
end
|
115
|
+
|
116
|
+
##################
|
117
|
+
|
118
|
+
def resource(uri)
|
119
|
+
RestClient::Resource.new("http://#{host}", user, password)[uri]
|
120
|
+
end
|
121
|
+
|
122
|
+
def get(uri, extra_headers={}) # :nodoc:
|
123
|
+
resource(uri).get(heroku_headers.merge(extra_headers))
|
124
|
+
end
|
125
|
+
|
126
|
+
def post(uri, payload="", extra_headers={}) # :nodoc:
|
127
|
+
resource(uri).post(payload, heroku_headers.merge(extra_headers))
|
128
|
+
end
|
129
|
+
|
130
|
+
def put(uri, payload, extra_headers={}) # :nodoc:
|
131
|
+
resource(uri).put(payload, heroku_headers.merge(extra_headers))
|
132
|
+
end
|
133
|
+
|
134
|
+
def delete(uri) # :nodoc:
|
135
|
+
resource(uri).delete
|
136
|
+
end
|
137
|
+
|
138
|
+
def heroku_headers # :nodoc:
|
139
|
+
{
|
140
|
+
'X-Heroku-API-Version' => '2',
|
141
|
+
'User-Agent' => "herokugarden-gem/#{self.class.version}",
|
142
|
+
}
|
143
|
+
end
|
144
|
+
|
145
|
+
def xml(raw) # :nodoc:
|
146
|
+
REXML::Document.new(raw)
|
147
|
+
end
|
148
|
+
|
149
|
+
def escape(value) # :nodoc:
|
150
|
+
escaped = URI.escape(value.to_s, Regexp.new("[^#{URI::PATTERN::UNRESERVED}]"))
|
151
|
+
escaped.gsub('.', '%2E') # not covered by the previous URI.escape
|
152
|
+
end
|
153
|
+
end
|
@@ -0,0 +1,450 @@
|
|
1
|
+
require 'fileutils'
|
2
|
+
|
3
|
+
# This wraps the Heroku::Client class with higher-level actions suitable for
|
4
|
+
# use from the command line.
|
5
|
+
class HerokuGarden::CommandLine
|
6
|
+
class CommandFailed < RuntimeError; end
|
7
|
+
|
8
|
+
def execute(command, args)
|
9
|
+
send(command, args)
|
10
|
+
rescue RestClient::Unauthorized
|
11
|
+
display "Authentication failure"
|
12
|
+
rescue RestClient::ResourceNotFound
|
13
|
+
display "Resource not found. (Did you mistype the app name?)"
|
14
|
+
rescue RestClient::RequestFailed => e
|
15
|
+
display extract_error(e.response)
|
16
|
+
rescue HerokuGarden::CommandLine::CommandFailed => e
|
17
|
+
display e.message
|
18
|
+
end
|
19
|
+
|
20
|
+
def list(args)
|
21
|
+
list = heroku.list
|
22
|
+
if list.size > 0
|
23
|
+
display list.join("\n")
|
24
|
+
else
|
25
|
+
display "You have no apps."
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def info(args)
|
30
|
+
name = args.shift.downcase.strip rescue ""
|
31
|
+
if name.length == 0 or name.slice(0, 1) == '-'
|
32
|
+
display "Usage: herokugarden info <app>"
|
33
|
+
else
|
34
|
+
attrs = heroku.info(name)
|
35
|
+
display "=== #{attrs[:name]}"
|
36
|
+
display "Web URL: http://#{attrs[:name]}.#{heroku.host}/"
|
37
|
+
display "Domain name: http://#{attrs[:domain_name]}/" if attrs[:domain_name]
|
38
|
+
display "Git Repo: git@#{heroku.host}:#{attrs[:name]}.git"
|
39
|
+
display "Mode: #{ attrs[:production] == 'true' ? 'production' : 'development' }"
|
40
|
+
display "Public: #{ attrs[:'share-public'] == 'true' ? 'true' : 'false' }"
|
41
|
+
|
42
|
+
first = true
|
43
|
+
lead = "Collaborators:"
|
44
|
+
attrs[:collaborators].each do |collaborator|
|
45
|
+
display "#{first ? lead : ' ' * lead.length} #{collaborator[:email]} (#{collaborator[:access]})"
|
46
|
+
first = false
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
def create(args)
|
52
|
+
name = args.shift.downcase.strip rescue nil
|
53
|
+
name = heroku.create(name, {})
|
54
|
+
display "Created http://#{name}.#{heroku.host}/ | git@#{heroku.host}:#{name}.git"
|
55
|
+
end
|
56
|
+
|
57
|
+
def update(args)
|
58
|
+
name = args.shift.downcase.strip rescue ""
|
59
|
+
raise CommandFailed, "Invalid app name" if name.length == 0 or name.slice(0, 1) == '-'
|
60
|
+
|
61
|
+
attributes = {}
|
62
|
+
extract_option(args, '--name') do |new_name|
|
63
|
+
attributes[:name] = new_name
|
64
|
+
end
|
65
|
+
extract_option(args, '--public', %w( true false )) do |public|
|
66
|
+
attributes[:share_public] = (public == 'true')
|
67
|
+
end
|
68
|
+
extract_option(args, '--mode', %w( production development )) do |mode|
|
69
|
+
attributes[:production] = (mode == 'production')
|
70
|
+
end
|
71
|
+
raise CommandFailed, "Nothing to update" if attributes.empty?
|
72
|
+
heroku.update(name, attributes)
|
73
|
+
|
74
|
+
app_name = attributes[:name] || name
|
75
|
+
display "http://#{app_name}.#{heroku.host}/ updated"
|
76
|
+
end
|
77
|
+
|
78
|
+
def clone(args)
|
79
|
+
name = args.shift.downcase.strip rescue ""
|
80
|
+
if name.length == 0 or name.slice(0, 1) == '-'
|
81
|
+
display "Usage: herokugarden clone <app>"
|
82
|
+
display "(this command is deprecated in favor of using the git repo url directly)"
|
83
|
+
else
|
84
|
+
cmd = "git clone #{git_repo_for(name)}"
|
85
|
+
display cmd
|
86
|
+
system cmd
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
def destroy(args)
|
91
|
+
name = args.shift.strip.downcase rescue ""
|
92
|
+
if name.length == 0 or name.slice(0, 1) == '-'
|
93
|
+
display "Usage: herokugarden destroy <app>"
|
94
|
+
else
|
95
|
+
heroku.destroy(name)
|
96
|
+
display "Destroyed #{name}"
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
def sharing(args)
|
101
|
+
name = args.shift.strip.downcase rescue ""
|
102
|
+
if name.length == 0 or name.slice(0, 1) == '-'
|
103
|
+
display "Usage: herokugarden sharing <app>"
|
104
|
+
else
|
105
|
+
access = extract_option(args, '--access', %w( edit view )) || 'view'
|
106
|
+
extract_option(args, '--add') do |email|
|
107
|
+
return add_collaborator(name, email, access)
|
108
|
+
end
|
109
|
+
extract_option(args, '--update') do |email|
|
110
|
+
return update_collaborator(name, email, access)
|
111
|
+
end
|
112
|
+
extract_option(args, '--remove') do |email|
|
113
|
+
return remove_collaborator(name, email)
|
114
|
+
end
|
115
|
+
return list_collaborators(name)
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
def collaborators(args)
|
120
|
+
sharing(args)
|
121
|
+
end
|
122
|
+
|
123
|
+
def add_collaborator(name, email, access)
|
124
|
+
display heroku.add_collaborator(name, email, access)
|
125
|
+
end
|
126
|
+
|
127
|
+
def update_collaborator(name, email, access)
|
128
|
+
heroku.update_collaborator(name, email, access)
|
129
|
+
display "Collaborator updated"
|
130
|
+
end
|
131
|
+
|
132
|
+
def remove_collaborator(name, email)
|
133
|
+
heroku.remove_collaborator(name, email)
|
134
|
+
display "Collaborator removed"
|
135
|
+
end
|
136
|
+
|
137
|
+
def list_collaborators(name)
|
138
|
+
list = heroku.list_collaborators(name)
|
139
|
+
display list.map { |c| "#{c[:email]} (#{c[:access]})" }.join("\n")
|
140
|
+
end
|
141
|
+
|
142
|
+
def git_repo_for(name)
|
143
|
+
"git@#{heroku.host}:#{name}.git"
|
144
|
+
end
|
145
|
+
|
146
|
+
def rake(args)
|
147
|
+
app_name = args.shift.strip.downcase rescue ""
|
148
|
+
cmd = args.join(' ')
|
149
|
+
if app_name.length == 0 or cmd.length == 0
|
150
|
+
display "Usage: herokugarden rake <app> <command>"
|
151
|
+
else
|
152
|
+
puts heroku.rake(app_name, cmd)
|
153
|
+
end
|
154
|
+
end
|
155
|
+
|
156
|
+
def git_transition(args)
|
157
|
+
unless File.exists?(Dir.pwd + '/.git')
|
158
|
+
raise HerokuGarden::CommandLine::CommandFailed, "The current directory is not a git repository.\nBe sure to cd into the app's directory and try again."
|
159
|
+
end
|
160
|
+
|
161
|
+
gitconfig = File.read(Dir.pwd + '/.git/config')
|
162
|
+
if gitconfig.match(/url = git@herokugarden\.com:(.+)\.git/)
|
163
|
+
raise HerokuGarden::CommandLine::CommandFailed, "This git repo for #{app_name} is already up to date and pointing at herokugarden.com."
|
164
|
+
end
|
165
|
+
if app_name_match = gitconfig.match(/url = git@heroku\.com:(.+)\.git/)
|
166
|
+
app_name = app_name_match[1]
|
167
|
+
begin
|
168
|
+
attempt_git_transition(gitconfig, app_name, 'heroku.com')
|
169
|
+
rescue RestClient::ResourceNotFound
|
170
|
+
begin
|
171
|
+
attempt_git_transition(gitconfig, app_name, 'herokugarden.com')
|
172
|
+
rescue RestClient::ResourceNotFound
|
173
|
+
raise HerokuGarden::CommandLine::CommandFailed, "The current directory does not contain a known Heroku app."
|
174
|
+
end
|
175
|
+
end
|
176
|
+
else
|
177
|
+
raise HerokuGarden::CommandLine::CommandFailed, "The current directory does not contain a known Heroku app."
|
178
|
+
end
|
179
|
+
end
|
180
|
+
|
181
|
+
def attempt_git_transition(gitconfig, app_name, domain)
|
182
|
+
heroku.host = domain
|
183
|
+
if [nil, 'true'].include?(heroku.info(app_name, true)[:stack2])
|
184
|
+
raise HerokuGarden::CommandLine::CommandFailed, "#{app_name} is running on the Heroku production platform.\nThis git repo URL is valid and does not need to be changed."
|
185
|
+
else
|
186
|
+
File.open(Dir.pwd + '/.git/config', 'w') do |f|
|
187
|
+
f.write gitconfig.gsub("url = git@heroku.com:#{app_name}.git", "url = git@herokugarden.com:#{app_name}.git")
|
188
|
+
end
|
189
|
+
display "This git repo for #{app_name} has been updated to the new herokugarden.com URL."
|
190
|
+
end
|
191
|
+
end
|
192
|
+
|
193
|
+
############
|
194
|
+
attr_accessor :credentials
|
195
|
+
|
196
|
+
def heroku # :nodoc:
|
197
|
+
@heroku ||= init_heroku
|
198
|
+
end
|
199
|
+
|
200
|
+
def init_heroku # :nodoc:
|
201
|
+
HerokuGarden::Client.new(user, password, ENV['HEROKU_HOST'] || 'herokugarden.com')
|
202
|
+
end
|
203
|
+
|
204
|
+
def user # :nodoc:
|
205
|
+
get_credentials
|
206
|
+
@credentials[0]
|
207
|
+
end
|
208
|
+
|
209
|
+
def password # :nodoc:
|
210
|
+
get_credentials
|
211
|
+
@credentials[1]
|
212
|
+
end
|
213
|
+
|
214
|
+
def credentials_file
|
215
|
+
"#{home_directory}/.heroku/herokugarden_credentials"
|
216
|
+
end
|
217
|
+
|
218
|
+
def get_credentials # :nodoc:
|
219
|
+
return if @credentials
|
220
|
+
unless @credentials = read_credentials
|
221
|
+
@credentials = ask_for_credentials
|
222
|
+
save_credentials
|
223
|
+
end
|
224
|
+
@credentials
|
225
|
+
end
|
226
|
+
|
227
|
+
def read_credentials
|
228
|
+
if File.exists? credentials_file
|
229
|
+
return File.read(credentials_file).split("\n")
|
230
|
+
end
|
231
|
+
end
|
232
|
+
|
233
|
+
def echo_off
|
234
|
+
system "stty -echo"
|
235
|
+
end
|
236
|
+
|
237
|
+
def echo_on
|
238
|
+
system "stty echo"
|
239
|
+
end
|
240
|
+
|
241
|
+
def ask_for_credentials
|
242
|
+
puts "Enter your Heroku credentials."
|
243
|
+
|
244
|
+
print "Email: "
|
245
|
+
user = gets.strip
|
246
|
+
|
247
|
+
print "Password: "
|
248
|
+
password = running_on_windows? ? ask_for_password_on_windows : ask_for_password
|
249
|
+
|
250
|
+
[ user, password ]
|
251
|
+
end
|
252
|
+
|
253
|
+
def ask_for_password_on_windows
|
254
|
+
require "Win32API"
|
255
|
+
char = nil
|
256
|
+
password = ''
|
257
|
+
|
258
|
+
while char = Win32API.new("crtdll", "_getch", [ ], "L").Call do
|
259
|
+
break if char == 10 || char == 13 # received carriage return or newline
|
260
|
+
if char == 127 || char == 8 # backspace and delete
|
261
|
+
password.slice!(-1, 1)
|
262
|
+
else
|
263
|
+
password << char.chr
|
264
|
+
end
|
265
|
+
end
|
266
|
+
puts
|
267
|
+
return password
|
268
|
+
end
|
269
|
+
|
270
|
+
def ask_for_password
|
271
|
+
echo_off
|
272
|
+
password = gets.strip
|
273
|
+
puts
|
274
|
+
echo_on
|
275
|
+
return password
|
276
|
+
end
|
277
|
+
|
278
|
+
def save_credentials
|
279
|
+
begin
|
280
|
+
write_credentials
|
281
|
+
add_key
|
282
|
+
rescue RestClient::Unauthorized => e
|
283
|
+
delete_credentials
|
284
|
+
raise e unless retry_login?
|
285
|
+
|
286
|
+
display "\nAuthentication failed"
|
287
|
+
@credentials = ask_for_credentials
|
288
|
+
@heroku = init_heroku
|
289
|
+
retry
|
290
|
+
rescue Exception => e
|
291
|
+
delete_credentials
|
292
|
+
raise e
|
293
|
+
end
|
294
|
+
end
|
295
|
+
|
296
|
+
def retry_login?
|
297
|
+
@login_attempts ||= 0
|
298
|
+
@login_attempts += 1
|
299
|
+
@login_attempts < 3
|
300
|
+
end
|
301
|
+
|
302
|
+
def write_credentials
|
303
|
+
FileUtils.mkdir_p(File.dirname(credentials_file))
|
304
|
+
File.open(credentials_file, 'w') do |f|
|
305
|
+
f.puts self.credentials
|
306
|
+
end
|
307
|
+
set_credentials_permissions
|
308
|
+
end
|
309
|
+
|
310
|
+
def set_credentials_permissions
|
311
|
+
FileUtils.chmod 0700, File.dirname(credentials_file)
|
312
|
+
FileUtils.chmod 0600, credentials_file
|
313
|
+
end
|
314
|
+
|
315
|
+
def delete_credentials
|
316
|
+
FileUtils.rm_f(credentials_file)
|
317
|
+
end
|
318
|
+
|
319
|
+
def extract_option(args, options, valid_values=nil)
|
320
|
+
values = options.is_a?(Array) ? options : [options]
|
321
|
+
return unless opt_index = args.select { |a| values.include? a }.first
|
322
|
+
opt_value = args[args.index(opt_index) + 1] rescue nil
|
323
|
+
|
324
|
+
# remove option from args
|
325
|
+
args.delete(opt_index)
|
326
|
+
args.delete(opt_value)
|
327
|
+
|
328
|
+
if valid_values
|
329
|
+
opt_value = opt_value.downcase if opt_value
|
330
|
+
raise CommandFailed, "Invalid value '#{opt_value}' for option #{values.last}" unless valid_values.include?(opt_value)
|
331
|
+
end
|
332
|
+
|
333
|
+
block_given? ? yield(opt_value) : opt_value
|
334
|
+
end
|
335
|
+
|
336
|
+
def keys(*args)
|
337
|
+
args = args.first # something weird with ruby argument passing
|
338
|
+
|
339
|
+
if args.empty?
|
340
|
+
list_keys
|
341
|
+
return
|
342
|
+
end
|
343
|
+
|
344
|
+
extract_option(args, '--add') do |keyfile|
|
345
|
+
add_key(keyfile)
|
346
|
+
return
|
347
|
+
end
|
348
|
+
extract_option(args, '--remove') do |arg|
|
349
|
+
remove_key(arg)
|
350
|
+
return
|
351
|
+
end
|
352
|
+
|
353
|
+
display "Usage: heroku keys [--add or --remove]"
|
354
|
+
end
|
355
|
+
|
356
|
+
def list_keys
|
357
|
+
keys = heroku.keys
|
358
|
+
if keys.empty?
|
359
|
+
display "No keys for #{user}"
|
360
|
+
else
|
361
|
+
display "=== #{keys.size} key#{keys.size > 1 ? 's' : ''} for #{user}"
|
362
|
+
keys.each do |key|
|
363
|
+
display key
|
364
|
+
end
|
365
|
+
end
|
366
|
+
end
|
367
|
+
|
368
|
+
def add_key(keyfile=nil)
|
369
|
+
keyfile ||= find_key
|
370
|
+
key = File.read(keyfile)
|
371
|
+
|
372
|
+
display "Uploading ssh public key #{keyfile}"
|
373
|
+
heroku.add_key(key)
|
374
|
+
end
|
375
|
+
|
376
|
+
def remove_key(arg)
|
377
|
+
if arg == 'all'
|
378
|
+
heroku.remove_all_keys
|
379
|
+
display "All keys removed."
|
380
|
+
else
|
381
|
+
heroku.remove_key(arg)
|
382
|
+
display "Key #{arg} removed."
|
383
|
+
end
|
384
|
+
end
|
385
|
+
|
386
|
+
def find_key
|
387
|
+
%w(rsa dsa).each do |key_type|
|
388
|
+
keyfile = "#{home_directory}/.ssh/id_#{key_type}.pub"
|
389
|
+
return keyfile if File.exists? keyfile
|
390
|
+
end
|
391
|
+
raise CommandFailed, "No ssh public key found in #{home_directory}/.ssh/id_[rd]sa.pub. You may want to specify the full path to the keyfile."
|
392
|
+
end
|
393
|
+
|
394
|
+
# vvv Deprecated
|
395
|
+
def upload_authkey(*args)
|
396
|
+
extract_key!
|
397
|
+
display "Uploading ssh public key"
|
398
|
+
display "(upload_authkey is deprecated, please use \"heroku keys --add\" instead)"
|
399
|
+
heroku.add_key(authkey)
|
400
|
+
end
|
401
|
+
|
402
|
+
def extract_key!
|
403
|
+
return unless key_path = extract_option(ARGV, ['-k', '--key'])
|
404
|
+
raise "Please inform the full path for your ssh public key" if File.directory?(key_path)
|
405
|
+
raise "Could not read ssh public key in #{key_path}" unless @ssh_key = authkey_read(key_path)
|
406
|
+
end
|
407
|
+
|
408
|
+
def authkey_type(key_type)
|
409
|
+
authkey_read("#{home_directory}/.ssh/id_#{key_type}.pub")
|
410
|
+
end
|
411
|
+
|
412
|
+
def authkey_read(filename)
|
413
|
+
File.read(filename) if File.exists?(filename)
|
414
|
+
end
|
415
|
+
|
416
|
+
def authkey
|
417
|
+
return @ssh_key if @ssh_key
|
418
|
+
%w( rsa dsa ).each do |key_type|
|
419
|
+
key = authkey_type(key_type)
|
420
|
+
return key if key
|
421
|
+
end
|
422
|
+
raise "Your ssh public key was not found. Make sure you have a rsa or dsa key in #{home_directory}/.ssh, or specify the full path to the keyfile."
|
423
|
+
end
|
424
|
+
# ^^^ Deprecated
|
425
|
+
|
426
|
+
def display(msg)
|
427
|
+
puts msg
|
428
|
+
end
|
429
|
+
|
430
|
+
def home_directory
|
431
|
+
running_on_windows? ? ENV['USERPROFILE'] : ENV['HOME']
|
432
|
+
end
|
433
|
+
|
434
|
+
def extract_error(response)
|
435
|
+
return "Not found" if response.code.to_i == 404
|
436
|
+
|
437
|
+
msg = parse_error_xml(response.body) rescue ''
|
438
|
+
msg = 'Internal server error' if msg.empty?
|
439
|
+
msg
|
440
|
+
end
|
441
|
+
|
442
|
+
def parse_error_xml(body)
|
443
|
+
xml_errors = REXML::Document.new(body).elements.to_a("//errors/error")
|
444
|
+
xml_errors.map { |a| a.text }.join(" / ")
|
445
|
+
end
|
446
|
+
|
447
|
+
def running_on_windows?
|
448
|
+
RUBY_PLATFORM =~ /mswin32/
|
449
|
+
end
|
450
|
+
end
|
data/spec/base.rb
ADDED
@@ -0,0 +1,13 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'spec'
|
3
|
+
require 'fileutils'
|
4
|
+
|
5
|
+
require File.dirname(__FILE__) + '/../lib/herokugarden'
|
6
|
+
require 'command_line'
|
7
|
+
|
8
|
+
class Module
|
9
|
+
def redefine_const(name, value)
|
10
|
+
__send__(:remove_const, name) if const_defined?(name)
|
11
|
+
const_set(name, value)
|
12
|
+
end
|
13
|
+
end
|
data/spec/client_spec.rb
ADDED
@@ -0,0 +1,157 @@
|
|
1
|
+
require File.dirname(__FILE__) + '/base'
|
2
|
+
|
3
|
+
describe HerokuGarden::Client do
|
4
|
+
before do
|
5
|
+
@client = HerokuGarden::Client.new(nil, nil)
|
6
|
+
@resource = mock('heroku rest resource')
|
7
|
+
end
|
8
|
+
|
9
|
+
it "list -> get a list of this user's apps" do
|
10
|
+
@client.should_receive(:resource).with('/apps').and_return(@resource)
|
11
|
+
@resource.should_receive(:get).and_return <<EOXML
|
12
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
13
|
+
<apps type="array">
|
14
|
+
<app><name>myapp1</name></app>
|
15
|
+
<app><name>myapp2</name></app>
|
16
|
+
</apps>
|
17
|
+
EOXML
|
18
|
+
@client.list.should == %w(myapp1 myapp2)
|
19
|
+
end
|
20
|
+
|
21
|
+
it "info -> get app attributes" do
|
22
|
+
@client.should_receive(:resource).with('/apps/myapp').and_return(@resource)
|
23
|
+
@resource.should_receive(:get).and_return <<EOXML
|
24
|
+
<?xml version='1.0' encoding='UTF-8'?>
|
25
|
+
<app>
|
26
|
+
<blessed type='boolean'>true</blessed>
|
27
|
+
<created-at type='datetime'>2008-07-08T17:21:50-07:00</created-at>
|
28
|
+
<id type='integer'>49134</id>
|
29
|
+
<name>testgems</name>
|
30
|
+
<production type='boolean'>true</production>
|
31
|
+
<share-public type='boolean'>true</share-public>
|
32
|
+
<domain_name/>
|
33
|
+
</app>
|
34
|
+
EOXML
|
35
|
+
@client.stub!(:list_collaborators).and_return([:jon, :mike])
|
36
|
+
@client.info('myapp').should == { :blessed => 'true', :'created-at' => '2008-07-08T17:21:50-07:00', :id => '49134', :name => 'testgems', :production => 'true', :'share-public' => 'true', :domain_name => nil, :collaborators => [:jon, :mike] }
|
37
|
+
end
|
38
|
+
|
39
|
+
it "create -> create a new blank app" do
|
40
|
+
@client.should_receive(:resource).with('/apps').and_return(@resource)
|
41
|
+
@resource.should_receive(:post).and_return <<EOXML
|
42
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
43
|
+
<app><name>untitled-123</name></app>
|
44
|
+
EOXML
|
45
|
+
@client.create.should == "untitled-123"
|
46
|
+
end
|
47
|
+
|
48
|
+
it "create(name) -> create a new blank app with a specified name" do
|
49
|
+
@client.should_receive(:resource).with('/apps').and_return(@resource)
|
50
|
+
@resource.should_receive(:post).with({ 'app[name]' => 'newapp' }, @client.heroku_headers).and_return <<EOXML
|
51
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
52
|
+
<app><name>newapp</name></app>
|
53
|
+
EOXML
|
54
|
+
@client.create("newapp").should == "newapp"
|
55
|
+
end
|
56
|
+
|
57
|
+
it "update(name, attributes) -> updates existing apps" do
|
58
|
+
@client.should_receive(:resource).with('/apps/myapp').and_return(@resource)
|
59
|
+
@resource.should_receive(:put).with({ :app => { :mode => 'production', :public => true } }, anything)
|
60
|
+
@client.update("myapp", :mode => 'production', :public => true)
|
61
|
+
end
|
62
|
+
|
63
|
+
it "destroy(name) -> destroy the named app" do
|
64
|
+
@client.should_receive(:resource).with('/apps/destroyme').and_return(@resource)
|
65
|
+
@resource.should_receive(:delete)
|
66
|
+
@client.destroy("destroyme")
|
67
|
+
end
|
68
|
+
|
69
|
+
it "rake(app_name, cmd) -> run a rake command on the app" do
|
70
|
+
@client.should_receive(:resource).with('/apps/myapp/rake').and_return(@resource)
|
71
|
+
@resource.should_receive(:post).with('db:migrate', @client.heroku_headers)
|
72
|
+
@client.rake('myapp', 'db:migrate')
|
73
|
+
end
|
74
|
+
|
75
|
+
describe "collaborators" do
|
76
|
+
it "list(app_name) -> list app collaborators" do
|
77
|
+
@client.should_receive(:resource).with('/apps/myapp/collaborators').and_return(@resource)
|
78
|
+
@resource.should_receive(:get).and_return <<EOXML
|
79
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
80
|
+
<collaborators type="array">
|
81
|
+
<collaborator><email>joe@example.com</email><access>edit</access></collaborator>
|
82
|
+
<collaborator><email>jon@example.com</email><access>view</access></collaborator>
|
83
|
+
</collaborators>
|
84
|
+
EOXML
|
85
|
+
@client.list_collaborators('myapp').should == [
|
86
|
+
{ :email => 'joe@example.com', :access => 'edit' },
|
87
|
+
{ :email => 'jon@example.com', :access => 'view' }
|
88
|
+
]
|
89
|
+
end
|
90
|
+
|
91
|
+
it "add_collaborator(app_name, email, access) -> adds collaborator to app" do
|
92
|
+
@client.should_receive(:resource).with('/apps/myapp/collaborators').and_return(@resource)
|
93
|
+
@resource.should_receive(:post).with({ 'collaborator[email]' => 'joe@example.com', 'collaborator[access]' => 'edit'}, anything)
|
94
|
+
@client.add_collaborator('myapp', 'joe@example.com', 'edit')
|
95
|
+
end
|
96
|
+
|
97
|
+
it "update_collaborator(app_name, email, access) -> updates existing collaborator record" do
|
98
|
+
@client.should_receive(:resource).with('/apps/myapp/collaborators/joe%40example%2Ecom').and_return(@resource)
|
99
|
+
@resource.should_receive(:put).with({ 'collaborator[access]' => 'view'}, anything)
|
100
|
+
@client.update_collaborator('myapp', 'joe@example.com', 'view')
|
101
|
+
end
|
102
|
+
|
103
|
+
it "remove_collaborator(app_name, email, access) -> removes collaborator from app" do
|
104
|
+
@client.should_receive(:resource).with('/apps/myapp/collaborators/joe%40example%2Ecom').and_return(@resource)
|
105
|
+
@resource.should_receive(:delete)
|
106
|
+
@client.remove_collaborator('myapp', 'joe@example.com')
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
describe "ssh keys" do
|
111
|
+
it "fetches a list of the user's current keys" do
|
112
|
+
@client.should_receive(:resource).with('/user/keys').and_return(@resource)
|
113
|
+
@resource.should_receive(:get).and_return <<EOXML
|
114
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
115
|
+
<keys type="array">
|
116
|
+
<key>
|
117
|
+
<contents>ssh-dss thekey== joe@workstation</contents>
|
118
|
+
</key>
|
119
|
+
</keys>
|
120
|
+
EOXML
|
121
|
+
@client.keys.should == [ "ssh-dss thekey== joe@workstation" ]
|
122
|
+
end
|
123
|
+
|
124
|
+
it "add_key(key) -> add an ssh key (e.g., the contents of id_rsa.pub) to the user" do
|
125
|
+
@client.should_receive(:resource).with('/user/keys').and_return(@resource)
|
126
|
+
@client.stub!(:heroku_headers).and_return({})
|
127
|
+
@resource.should_receive(:post).with('a key', 'Content-Type' => 'text/ssh-authkey')
|
128
|
+
@client.add_key('a key')
|
129
|
+
end
|
130
|
+
|
131
|
+
it "remove_key(key) -> remove an ssh key by name (user@box)" do
|
132
|
+
@client.should_receive(:resource).with('/user/keys/joe%40workstation').and_return(@resource)
|
133
|
+
@resource.should_receive(:delete)
|
134
|
+
@client.remove_key('joe@workstation')
|
135
|
+
end
|
136
|
+
|
137
|
+
it "remove_all_keys -> removes all ssh keys for the user" do
|
138
|
+
@client.should_receive(:resource).with('/user/keys').and_return(@resource)
|
139
|
+
@resource.should_receive(:delete)
|
140
|
+
@client.remove_all_keys
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
describe "internal" do
|
145
|
+
it "creates a RestClient resource for making calls" do
|
146
|
+
@client.stub!(:host).and_return('heroku.com')
|
147
|
+
@client.stub!(:user).and_return('joe@example.com')
|
148
|
+
@client.stub!(:password).and_return('secret')
|
149
|
+
|
150
|
+
res = @client.resource('/xyz')
|
151
|
+
|
152
|
+
res.url.should == 'http://heroku.com/xyz'
|
153
|
+
res.user.should == 'joe@example.com'
|
154
|
+
res.password.should == 'secret'
|
155
|
+
end
|
156
|
+
end
|
157
|
+
end
|
@@ -0,0 +1,334 @@
|
|
1
|
+
require File.dirname(__FILE__) + '/base'
|
2
|
+
|
3
|
+
describe HerokuGarden::CommandLine do
|
4
|
+
before do
|
5
|
+
@cli = HerokuGarden::CommandLine.new
|
6
|
+
@cli.stub!(:display)
|
7
|
+
@cli.stub!(:print)
|
8
|
+
@cli.stub!(:ask_for_credentials).and_raise("ask_for_credentials should not be called by specs")
|
9
|
+
end
|
10
|
+
|
11
|
+
describe "credentials" do
|
12
|
+
it "reads credentials from the credentials file" do
|
13
|
+
sandbox = "/tmp/cli_spec_#{Process.pid}"
|
14
|
+
File.open(sandbox, "w") { |f| f.write "user\npass\n" }
|
15
|
+
@cli.stub!(:credentials_file).and_return(sandbox)
|
16
|
+
@cli.read_credentials.should == %w(user pass)
|
17
|
+
end
|
18
|
+
|
19
|
+
it "takes the user from the first line and the password from the second line" do
|
20
|
+
@cli.stub!(:read_credentials).and_return(%w(user pass))
|
21
|
+
@cli.user.should == 'user'
|
22
|
+
@cli.password.should == 'pass'
|
23
|
+
end
|
24
|
+
|
25
|
+
it "asks for credentials when the file doesn't exist" do
|
26
|
+
sandbox = "/tmp/cli_spec_#{Process.pid}"
|
27
|
+
FileUtils.rm_rf(sandbox)
|
28
|
+
@cli.stub!(:credentials_file).and_return(sandbox)
|
29
|
+
@cli.should_receive(:ask_for_credentials).and_return([ 'u', 'p'])
|
30
|
+
@cli.should_receive(:save_credentials)
|
31
|
+
@cli.get_credentials.should == [ 'u', 'p' ]
|
32
|
+
end
|
33
|
+
|
34
|
+
it "writes the credentials to a file" do
|
35
|
+
sandbox = "/tmp/cli_spec_#{Process.pid}"
|
36
|
+
FileUtils.rm_rf(sandbox)
|
37
|
+
@cli.stub!(:credentials_file).and_return(sandbox)
|
38
|
+
@cli.stub!(:credentials).and_return(['one', 'two'])
|
39
|
+
@cli.should_receive(:set_credentials_permissions)
|
40
|
+
@cli.write_credentials
|
41
|
+
File.read(sandbox).should == "one\ntwo\n"
|
42
|
+
end
|
43
|
+
|
44
|
+
it "sets ~/.heroku/credentials to be readable only by the user" do
|
45
|
+
sandbox = "/tmp/cli_spec_#{Process.pid}"
|
46
|
+
FileUtils.rm_rf(sandbox)
|
47
|
+
FileUtils.mkdir_p(sandbox)
|
48
|
+
fname = "#{sandbox}/file"
|
49
|
+
system "touch #{fname}"
|
50
|
+
@cli.stub!(:credentials_file).and_return(fname)
|
51
|
+
@cli.set_credentials_permissions
|
52
|
+
File.stat(sandbox).mode.should == 040700
|
53
|
+
File.stat(fname).mode.should == 0100600
|
54
|
+
end
|
55
|
+
|
56
|
+
it "writes credentials and uploads authkey when credentials are saved" do
|
57
|
+
@cli.stub!(:credentials)
|
58
|
+
@cli.should_receive(:write_credentials)
|
59
|
+
@cli.should_receive(:add_key)
|
60
|
+
@cli.save_credentials
|
61
|
+
end
|
62
|
+
|
63
|
+
it "save_credentials deletes the credentials when the upload authkey is unauthorized" do
|
64
|
+
@cli.stub!(:write_credentials)
|
65
|
+
@cli.stub!(:retry_login?).and_return(false)
|
66
|
+
@cli.should_receive(:add_key).and_raise(RestClient::Unauthorized)
|
67
|
+
@cli.should_receive(:delete_credentials)
|
68
|
+
lambda { @cli.save_credentials }.should raise_error(RestClient::Unauthorized)
|
69
|
+
end
|
70
|
+
|
71
|
+
it "save_credentials deletes the credentials when there's no authkey" do
|
72
|
+
@cli.stub!(:write_credentials)
|
73
|
+
@cli.should_receive(:add_key).and_raise(RuntimeError)
|
74
|
+
@cli.should_receive(:delete_credentials)
|
75
|
+
lambda { @cli.save_credentials }.should raise_error
|
76
|
+
end
|
77
|
+
|
78
|
+
it "save_credentials deletes the credentials when the authkey is weak" do
|
79
|
+
@cli.stub!(:write_credentials)
|
80
|
+
@cli.should_receive(:add_key).and_raise(RestClient::RequestFailed)
|
81
|
+
@cli.should_receive(:delete_credentials)
|
82
|
+
lambda { @cli.save_credentials }.should raise_error
|
83
|
+
end
|
84
|
+
|
85
|
+
it "asks for login again when not authorized, for three times" do
|
86
|
+
@cli.stub!(:write_credentials)
|
87
|
+
@cli.stub!(:delete_credentials)
|
88
|
+
@cli.stub!(:add_key).and_raise(RestClient::Unauthorized)
|
89
|
+
@cli.should_receive(:ask_for_credentials).exactly(4).times
|
90
|
+
lambda { @cli.save_credentials }.should raise_error(RestClient::Unauthorized)
|
91
|
+
end
|
92
|
+
|
93
|
+
it "deletes the credentials file" do
|
94
|
+
FileUtils.should_receive(:rm_f).with(@cli.credentials_file)
|
95
|
+
@cli.delete_credentials
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
describe "key management" do
|
100
|
+
before do
|
101
|
+
@cli.instance_variable_set('@credentials', %w(user pass))
|
102
|
+
end
|
103
|
+
|
104
|
+
it "finds the user's ssh key in ~/ssh/id_rsa.pub" do
|
105
|
+
@cli.stub!(:home_directory).and_return('/home/joe')
|
106
|
+
File.should_receive(:exists?).with('/home/joe/.ssh/id_rsa.pub').and_return(true)
|
107
|
+
@cli.find_key.should == '/home/joe/.ssh/id_rsa.pub'
|
108
|
+
end
|
109
|
+
|
110
|
+
it "finds the user's ssh key in ~/ssh/id_dsa.pub" do
|
111
|
+
@cli.stub!(:home_directory).and_return('/home/joe')
|
112
|
+
File.should_receive(:exists?).with('/home/joe/.ssh/id_rsa.pub').and_return(false)
|
113
|
+
File.should_receive(:exists?).with('/home/joe/.ssh/id_dsa.pub').and_return(true)
|
114
|
+
@cli.find_key.should == '/home/joe/.ssh/id_dsa.pub'
|
115
|
+
end
|
116
|
+
|
117
|
+
it "raises an exception if neither id_rsa or id_dsa were found" do
|
118
|
+
@cli.stub!(:home_directory).and_return('/home/joe')
|
119
|
+
File.stub!(:exists?).and_return(false)
|
120
|
+
lambda { @cli.find_key }.should raise_error(HerokuGarden::CommandLine::CommandFailed)
|
121
|
+
end
|
122
|
+
|
123
|
+
it "adds a key from the default locations if no key filename is supplied" do
|
124
|
+
@cli.should_receive(:find_key).and_return('/home/joe/.ssh/id_rsa.pub')
|
125
|
+
File.should_receive(:read).with('/home/joe/.ssh/id_rsa.pub').and_return('ssh-rsa xyz')
|
126
|
+
@cli.heroku.should_receive(:add_key).with('ssh-rsa xyz')
|
127
|
+
@cli.add_key
|
128
|
+
end
|
129
|
+
|
130
|
+
it "adds a key from a specified keyfile path" do
|
131
|
+
@cli.should_not_receive(:find_key)
|
132
|
+
File.should_receive(:read).with('/my/key.pub').and_return('ssh-rsa xyz')
|
133
|
+
@cli.heroku.should_receive(:add_key).with('ssh-rsa xyz')
|
134
|
+
@cli.add_key('/my/key.pub')
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
describe "deprecated key management" do
|
139
|
+
it "gets pub keys from the user's home directory" do
|
140
|
+
@cli.should_receive(:home_directory).and_return('/Users/joe')
|
141
|
+
File.should_receive(:exists?).with('/Users/joe/.ssh/id_xyz.pub').and_return(true)
|
142
|
+
File.should_receive(:read).with('/Users/joe/.ssh/id_xyz.pub').and_return('ssh-xyz somehexkey')
|
143
|
+
@cli.authkey_type('xyz').should == 'ssh-xyz somehexkey'
|
144
|
+
end
|
145
|
+
|
146
|
+
it "gets the rsa key" do
|
147
|
+
@cli.stub!(:authkey_type).with('rsa').and_return('ssh-rsa somehexkey')
|
148
|
+
@cli.authkey.should == 'ssh-rsa somehexkey'
|
149
|
+
end
|
150
|
+
|
151
|
+
it "gets the dsa key when there's no rsa" do
|
152
|
+
@cli.stub!(:authkey_type).with('rsa').and_return(nil)
|
153
|
+
@cli.stub!(:authkey_type).with('dsa').and_return('ssh-dsa somehexkey')
|
154
|
+
@cli.authkey.should == 'ssh-dsa somehexkey'
|
155
|
+
end
|
156
|
+
|
157
|
+
it "raises a friendly error message when no key is found" do
|
158
|
+
@cli.stub!(:authkey_type).with('rsa').and_return(nil)
|
159
|
+
@cli.stub!(:authkey_type).with('dsa').and_return(nil)
|
160
|
+
lambda { @cli.authkey }.should raise_error
|
161
|
+
end
|
162
|
+
|
163
|
+
it "accepts a custom key via the -k parameter" do
|
164
|
+
Object.redefine_const(:ARGV, ['-k', '/Users/joe/sshkeys/mykey.pub'])
|
165
|
+
@cli.should_receive(:authkey_read).with('/Users/joe/sshkeys/mykey.pub').and_return('ssh-rsa somehexkey')
|
166
|
+
@cli.extract_key!
|
167
|
+
@cli.authkey.should == 'ssh-rsa somehexkey'
|
168
|
+
end
|
169
|
+
|
170
|
+
it "extracts options from ARGV" do
|
171
|
+
Object.redefine_const(:ARGV, %w( a b --something value c d ))
|
172
|
+
@cli.extract_option(ARGV, '--something').should == 'value'
|
173
|
+
ARGV.should == %w( a b c d )
|
174
|
+
end
|
175
|
+
|
176
|
+
it "rejects options outside valid values for ARGV" do
|
177
|
+
Object.redefine_const(:ARGV, %w( -boolean_option t ))
|
178
|
+
lambda { @cli.extract_argv_option('-boolean_option', %w( true false )) }.should raise_error
|
179
|
+
end
|
180
|
+
|
181
|
+
it "uploads the ssh authkey (deprecated in favor of add_key)" do
|
182
|
+
@cli.should_receive(:extract_key!)
|
183
|
+
@cli.should_receive(:authkey).and_return('my key')
|
184
|
+
heroku = mock("heroku client")
|
185
|
+
@cli.should_receive(:init_heroku).and_return(heroku)
|
186
|
+
heroku.should_receive(:add_key).with('my key')
|
187
|
+
@cli.upload_authkey
|
188
|
+
end
|
189
|
+
|
190
|
+
it "gets the home directory from HOME when running on *nix" do
|
191
|
+
ENV.should_receive(:[]).with('HOME').and_return(@home)
|
192
|
+
@cli.stub!(:running_on_windows?).and_return(false)
|
193
|
+
@cli.home_directory.should == @home
|
194
|
+
end
|
195
|
+
|
196
|
+
it "gets the home directory from USERPROFILE when running on windows" do
|
197
|
+
ENV.should_receive(:[]).with('USERPROFILE').and_return(@home)
|
198
|
+
@cli.stub!(:running_on_windows?).and_return(true)
|
199
|
+
@cli.home_directory.should == @home
|
200
|
+
end
|
201
|
+
|
202
|
+
it "detects it's running on windows" do
|
203
|
+
Object.redefine_const(:RUBY_PLATFORM, 'i386-mswin32')
|
204
|
+
@cli.should be_running_on_windows
|
205
|
+
end
|
206
|
+
|
207
|
+
it "doesn't consider cygwin as windows" do
|
208
|
+
Object.redefine_const(:RUBY_PLATFORM, 'i386-cygwin')
|
209
|
+
@cli.should_not be_running_on_windows
|
210
|
+
end
|
211
|
+
end
|
212
|
+
|
213
|
+
describe "execute" do
|
214
|
+
it "executes an action" do
|
215
|
+
@cli.should_receive(:my_action).with(%w(arg1 arg2))
|
216
|
+
@cli.execute('my_action', %w(arg1 arg2))
|
217
|
+
end
|
218
|
+
|
219
|
+
it "catches unauthorized errors" do
|
220
|
+
@cli.should_receive(:my_action).and_raise(RestClient::Unauthorized)
|
221
|
+
@cli.should_receive(:display).with('Authentication failure')
|
222
|
+
@cli.execute('my_action', 'args')
|
223
|
+
end
|
224
|
+
|
225
|
+
it "parses rails-format error xml" do
|
226
|
+
@cli.parse_error_xml('<errors><error>Error 1</error><error>Error 2</error></errors>').should == 'Error 1 / Error 2'
|
227
|
+
end
|
228
|
+
|
229
|
+
it "does not catch general exceptions, those are shown to the user as normal" do
|
230
|
+
@cli.should_receive(:my_action).and_raise(RuntimeError)
|
231
|
+
lambda { @cli.execute('my_action', 'args') }.should raise_error(RuntimeError)
|
232
|
+
end
|
233
|
+
end
|
234
|
+
|
235
|
+
describe "app actions" do
|
236
|
+
before do
|
237
|
+
@cli.instance_variable_set('@credentials', %w(user pass))
|
238
|
+
end
|
239
|
+
|
240
|
+
it "shows app info" do
|
241
|
+
@cli.heroku.should_receive(:info).with('myapp').and_return({ :name => 'myapp', :collaborators => [] })
|
242
|
+
@cli.heroku.stub!(:domain).and_return('heroku.com')
|
243
|
+
@cli.should_receive(:display).with('=== myapp')
|
244
|
+
@cli.should_receive(:display).with('Web URL: http://myapp.herokugarden.com/')
|
245
|
+
@cli.info([ 'myapp' ])
|
246
|
+
end
|
247
|
+
|
248
|
+
it "creates without a name" do
|
249
|
+
@cli.heroku.should_receive(:create).with(nil, {}).and_return("untitled-123")
|
250
|
+
@cli.create([])
|
251
|
+
end
|
252
|
+
|
253
|
+
it "creates with a name" do
|
254
|
+
@cli.heroku.should_receive(:create).with('myapp', {}).and_return("myapp")
|
255
|
+
@cli.create([ 'myapp' ])
|
256
|
+
end
|
257
|
+
|
258
|
+
it "updates app" do
|
259
|
+
@cli.heroku.should_receive(:update).with('myapp', { :name => 'myapp2', :share_public => true, :production => true })
|
260
|
+
@cli.update([ 'myapp', '--name', 'myapp2', '--public', 'true', '--mode', 'production' ])
|
261
|
+
end
|
262
|
+
|
263
|
+
it "clones the app (deprecated in favor of straight git clone)" do
|
264
|
+
@cli.should_receive(:system).with('git clone git@herokugarden.com:myapp.git')
|
265
|
+
@cli.clone([ 'myapp' ])
|
266
|
+
end
|
267
|
+
end
|
268
|
+
|
269
|
+
describe "collaborators" do
|
270
|
+
before do
|
271
|
+
@cli.instance_variable_set('@credentials', %w(user pass))
|
272
|
+
end
|
273
|
+
|
274
|
+
it "list collaborators when there's just the app name" do
|
275
|
+
@cli.heroku.should_receive(:list_collaborators).and_return([])
|
276
|
+
@cli.collaborators(['myapp'])
|
277
|
+
end
|
278
|
+
|
279
|
+
it "add collaborators with default access to view only" do
|
280
|
+
@cli.heroku.should_receive(:add_collaborator).with('myapp', 'joe@example.com', 'view')
|
281
|
+
@cli.collaborators(['myapp', '--add', 'joe@example.com'])
|
282
|
+
end
|
283
|
+
|
284
|
+
it "add collaborators with edit access" do
|
285
|
+
@cli.heroku.should_receive(:add_collaborator).with('myapp', 'joe@example.com', 'edit')
|
286
|
+
@cli.collaborators(['myapp', '--add', 'joe@example.com', '--access', 'edit'])
|
287
|
+
end
|
288
|
+
|
289
|
+
it "updates collaborators" do
|
290
|
+
@cli.heroku.should_receive(:update_collaborator).with('myapp', 'joe@example.com', 'view')
|
291
|
+
@cli.collaborators(['myapp', '--update', 'joe@example.com', '--access', 'view'])
|
292
|
+
end
|
293
|
+
|
294
|
+
it "removes collaborators" do
|
295
|
+
@cli.heroku.should_receive(:remove_collaborator).with('myapp', 'joe@example.com')
|
296
|
+
@cli.collaborators(['myapp', '--remove', 'joe@example.com'])
|
297
|
+
end
|
298
|
+
end
|
299
|
+
|
300
|
+
describe "git transition" do
|
301
|
+
before do
|
302
|
+
@cli.instance_variable_set('@credentials', %w(user pass))
|
303
|
+
Dir.stub!(:pwd).and_return('/apps/myapp')
|
304
|
+
end
|
305
|
+
|
306
|
+
it "requires a git repo" do
|
307
|
+
File.stub!(:exists?).with('/apps/myapp/.git').and_return(false)
|
308
|
+
lambda { @cli.git_transition([]) }.should raise_error(HerokuGarden::CommandLine::CommandFailed)
|
309
|
+
end
|
310
|
+
|
311
|
+
it "requires a remote entry with git@heroku.com:appname.git" do
|
312
|
+
File.stub!(:exists?).with('/apps/myapp/.git').and_return(true)
|
313
|
+
File.stub!(:read).with('/apps/myapp/.git/config').and_return('config without app')
|
314
|
+
lambda { @cli.git_transition([]) }.should raise_error(HerokuGarden::CommandLine::CommandFailed)
|
315
|
+
end
|
316
|
+
|
317
|
+
it "doesn't update stack2 apps" do
|
318
|
+
File.stub!(:exists?).with('/apps/myapp/.git').and_return(true)
|
319
|
+
File.stub!(:read).with('/apps/myapp/.git/config').and_return("[remote \"heroku\"]\n\turl = git@heroku.com:myapp.git")
|
320
|
+
@cli.heroku.should_receive(:info).with('myapp', true).and_return({ :stack2 => 'true' })
|
321
|
+
lambda { @cli.git_transition([]) }.should raise_error(HerokuGarden::CommandLine::CommandFailed)
|
322
|
+
end
|
323
|
+
|
324
|
+
it "updates the gitconfig" do
|
325
|
+
File.stub!(:exists?).with('/apps/myapp/.git').and_return(true)
|
326
|
+
File.stub!(:read).with('/apps/myapp/.git/config').and_return("[remote \"heroku\"]\n\turl = git@heroku.com:myapp.git")
|
327
|
+
@cli.heroku.should_receive(:info).with('myapp', true).and_return({ :stack2 => 'false' })
|
328
|
+
gitconfig = mock('git config')
|
329
|
+
File.should_receive(:open).with('/apps/myapp/.git/config', 'w').and_yield(gitconfig)
|
330
|
+
gitconfig.should_receive(:write).with("[remote \"heroku\"]\n\turl = git@herokugarden.com:myapp.git")
|
331
|
+
@cli.git_transition([])
|
332
|
+
end
|
333
|
+
end
|
334
|
+
end
|
metadata
ADDED
@@ -0,0 +1,77 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: herokugarden
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.4.2
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Adam Wiggins
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
|
12
|
+
date: 2009-01-14 00:00:00 -08:00
|
13
|
+
default_executable: herokugarden
|
14
|
+
dependencies:
|
15
|
+
- !ruby/object:Gem::Dependency
|
16
|
+
name: rest-client
|
17
|
+
type: :runtime
|
18
|
+
version_requirement:
|
19
|
+
version_requirements: !ruby/object:Gem::Requirement
|
20
|
+
requirements:
|
21
|
+
- - ">="
|
22
|
+
- !ruby/object:Gem::Version
|
23
|
+
version: "0.5"
|
24
|
+
version:
|
25
|
+
description: Client library and command-line tool to manage and deploy Rails apps on Heroku Garden.
|
26
|
+
email: feedback@heroku.com
|
27
|
+
executables:
|
28
|
+
- herokugarden
|
29
|
+
extensions: []
|
30
|
+
|
31
|
+
extra_rdoc_files: []
|
32
|
+
|
33
|
+
files:
|
34
|
+
- Rakefile
|
35
|
+
- bin/herokugarden
|
36
|
+
- lib/herokugarden
|
37
|
+
- lib/herokugarden/client.rb
|
38
|
+
- lib/herokugarden/command_line.rb
|
39
|
+
- lib/herokugarden.rb
|
40
|
+
- spec/base.rb
|
41
|
+
- spec/client_spec.rb
|
42
|
+
- spec/command_line_spec.rb
|
43
|
+
has_rdoc: true
|
44
|
+
homepage: http://herokugarden.com/
|
45
|
+
post_install_message: |+
|
46
|
+
|
47
|
+
---> Installing herokugarden v0.4.2
|
48
|
+
---> Migrate local checkouts using the git:transition command:
|
49
|
+
|
50
|
+
cd myapp/
|
51
|
+
herokugarden git:transition
|
52
|
+
|
53
|
+
rdoc_options: []
|
54
|
+
|
55
|
+
require_paths:
|
56
|
+
- lib
|
57
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: "0"
|
62
|
+
version:
|
63
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
64
|
+
requirements:
|
65
|
+
- - ">="
|
66
|
+
- !ruby/object:Gem::Version
|
67
|
+
version: "0"
|
68
|
+
version:
|
69
|
+
requirements: []
|
70
|
+
|
71
|
+
rubyforge_project: herokugarden
|
72
|
+
rubygems_version: 1.2.0
|
73
|
+
signing_key:
|
74
|
+
specification_version: 2
|
75
|
+
summary: Client library and CLI to deploy Rails apps on Heroku Garden.
|
76
|
+
test_files: []
|
77
|
+
|