herokugarden 0.4.2
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/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
|
+
|