gist 3.0.2 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/gist.rb CHANGED
@@ -1,301 +1,359 @@
1
- require 'open-uri'
2
1
  require 'net/https'
3
- require 'optparse'
4
-
5
- require 'rubygems'
2
+ require 'cgi'
6
3
  require 'json'
7
- require 'base64'
8
-
9
- require 'gist/manpage' unless defined?(Gist::Manpage)
10
- require 'gist/version' unless defined?(Gist::Version)
11
-
12
- # You can use this class from other scripts with the greatest of
13
- # ease.
14
- #
15
- # >> Gist.read(gist_id)
16
- # Returns the body of gist_id as a string.
17
- #
18
- # >> Gist.write(content)
19
- # Creates a gist from the string `content`. Returns the URL of the
20
- # new gist.
21
- #
22
- # >> Gist.copy(string)
23
- # Copies string to the clipboard.
24
- #
25
- # >> Gist.browse(url)
26
- # Opens URL in your default browser.
4
+ require 'uri'
5
+
6
+ # It just gists.
27
7
  module Gist
28
8
  extend self
29
9
 
30
- GIST_URL = 'https://api.github.com/gists/%s'
31
- CREATE_URL = 'https://api.github.com/gists'
32
-
33
- if ENV['HTTPS_PROXY']
34
- PROXY = URI(ENV['HTTPS_PROXY'])
35
- elsif ENV['HTTP_PROXY']
36
- PROXY = URI(ENV['HTTP_PROXY'])
37
- else
38
- PROXY = nil
39
- end
40
- PROXY_HOST = PROXY ? PROXY.host : nil
41
- PROXY_PORT = PROXY ? PROXY.port : nil
42
-
43
- # Parses command line arguments and does what needs to be done.
44
- def execute(*args)
45
- private_gist = defaults["private"]
46
- gist_filename = nil
47
- gist_extension = defaults["extension"]
48
- browse_enabled = defaults["browse"]
49
- description = nil
50
-
51
- opts = OptionParser.new do |opts|
52
- opts.banner = "Usage: gist [options] [filename or stdin] [filename] ...\n" +
53
- "Filename '-' forces gist to read from stdin."
54
-
55
- opts.on('-p', '--[no-]private', 'Make the gist private') do |priv|
56
- private_gist = priv
57
- end
10
+ VERSION = '4.0.0'
58
11
 
59
- t_desc = 'Set syntax highlighting of the Gist by file extension'
60
- opts.on('-t', '--type [EXTENSION]', t_desc) do |extension|
61
- gist_extension = '.' + extension
62
- end
12
+ # A list of clipboard commands with copy and paste support.
13
+ CLIPBOARD_COMMANDS = {
14
+ 'xclip' => 'xclip -o',
15
+ 'xsel' => 'xsel -o',
16
+ 'pbcopy' => 'pbpaste',
17
+ 'putclip' => 'getclip'
18
+ }
63
19
 
64
- opts.on('-d','--description DESCRIPTION', 'Set description of the new gist') do |d|
65
- description = d
66
- end
20
+ GITHUB_API_URL = URI("https://api.github.com/")
21
+ GIT_IO_URL = URI("http://git.io")
67
22
 
68
- opts.on('-o','--[no-]open', 'Open gist in browser') do |o|
69
- browse_enabled = o
70
- end
23
+ GITHUB_BASE_PATH = ""
24
+ GHE_BASE_PATH = "/api/v3"
71
25
 
72
- opts.on('-m', '--man', 'Print manual') do
73
- Gist::Manpage.display("gist")
74
- end
26
+ URL_ENV_NAME = "GITHUB_URL"
75
27
 
76
- opts.on('-v', '--version', 'Print version') do
77
- puts Gist::Version
78
- exit
79
- end
28
+ USER_AGENT = "gist/#{VERSION} (Net::HTTP, #{RUBY_DESCRIPTION})"
80
29
 
81
- opts.on('-h', '--help', 'Display this screen') do
82
- puts opts
83
- exit
84
- end
30
+ # Exception tag for errors raised while gisting.
31
+ module Error;
32
+ def self.exception(*args)
33
+ RuntimeError.new(*args).extend(self)
85
34
  end
35
+ end
36
+ class ClipboardError < RuntimeError; include Error end
86
37
 
87
- begin
38
+ # Upload a gist to https://gist.github.com
39
+ #
40
+ # @param [String] content the code you'd like to gist
41
+ # @param [Hash] options more detailed options, see
42
+ # the documentation for {multi_gist}
43
+ #
44
+ # @see http://developer.github.com/v3/gists/
45
+ def gist(content, options = {})
46
+ filename = options[:filename] || "a.rb"
47
+ multi_gist({filename => content}, options)
48
+ end
88
49
 
89
- opts.parse!(args)
50
+ # Upload a gist to https://gist.github.com
51
+ #
52
+ # @param [Hash] files the code you'd like to gist: filename => content
53
+ # @param [Hash] options more detailed options
54
+ #
55
+ # @option options [String] :description the description
56
+ # @option options [Boolean] :public (false) is this gist public
57
+ # @option options [Boolean] :anonymous (false) is this gist anonymous
58
+ # @option options [String] :access_token (`File.read("~/.gist")`) The OAuth2 access token.
59
+ # @option options [String] :update the URL or id of a gist to update
60
+ # @option options [Boolean] :copy (false) Copy resulting URL to clipboard, if successful.
61
+ # @option options [Boolean] :open (false) Open the resulting URL in a browser.
62
+ # @option options [Symbol] :output (:all) The type of return value you'd like:
63
+ # :html_url gives a String containing the url to the gist in a browser
64
+ # :short_url gives a String contianing a git.io url that redirects to html_url
65
+ # :javascript gives a String containing a script tag suitable for embedding the gist
66
+ # :all gives a Hash containing the parsed json response from the server
67
+ #
68
+ # @return [String, Hash] the return value as configured by options[:output]
69
+ # @raise [Gist::Error] if something went wrong
70
+ #
71
+ # @see http://developer.github.com/v3/gists/
72
+ def multi_gist(files, options={})
73
+ json = {}
90
74
 
91
- if $stdin.tty? && args[0] != '-'
92
- # Run without stdin.
75
+ json[:description] = options[:description] if options[:description]
76
+ json[:public] = !!options[:public]
77
+ json[:files] = {}
78
+
79
+ files.each_pair do |(name, content)|
80
+ raise "Cannot gist empty files" if content.to_s.strip == ""
81
+ json[:files][File.basename(name)] = {:content => content}
82
+ end
83
+
84
+ existing_gist = options[:update].to_s.split("/").last
85
+ if options[:anonymous]
86
+ access_token = nil
87
+ else
88
+ access_token = (options[:access_token] || File.read(auth_token_file) rescue nil)
89
+ end
93
90
 
94
- if args.empty?
95
- # No args, print help.
96
- puts opts
97
- exit
98
- end
91
+ url = "#{base_path}/gists"
92
+ url << "/" << CGI.escape(existing_gist) if existing_gist.to_s != ''
93
+ url << "?access_token=" << CGI.escape(access_token) if access_token.to_s != ''
99
94
 
100
- files = args.inject([]) do |files, file|
101
- # Check if arg is a file. If so, grab the content.
102
- abort "Can't find #{file}" unless File.exists?(file)
95
+ request = Net::HTTP::Post.new(url)
96
+ request.body = JSON.dump(json)
97
+ request.content_type = 'application/json'
103
98
 
104
- files.push({
105
- :input => File.read(file),
106
- :filename => file,
107
- :extension => (File.extname(file) if file.include?('.'))
108
- })
109
- end
99
+ retried = false
110
100
 
101
+ begin
102
+ response = http(api_url, request)
103
+ if Net::HTTPSuccess === response
104
+ on_success(response.body, options)
111
105
  else
112
- # Read from standard input.
113
- input = $stdin.read
114
- files = [{:input => input, :extension => gist_extension}]
106
+ raise "Got #{response.class} from gist: #{response.body}"
115
107
  end
116
-
117
- url = write(files, private_gist, description)
118
- browse(url) if browse_enabled
119
- puts copy(url)
120
108
  rescue => e
121
- warn e
122
- puts opts
109
+ raise if retried
110
+ retried = true
111
+ retry
123
112
  end
124
- end
125
113
 
126
- # Create a gist on gist.github.com
127
- def write(files, private_gist = false, description = nil)
128
- url = URI.parse(CREATE_URL)
114
+ rescue => e
115
+ raise e.extend Error
116
+ end
129
117
 
130
- if PROXY_HOST
131
- proxy = Net::HTTP::Proxy(PROXY_HOST, PROXY_PORT)
132
- http = proxy.new(url.host, url.port)
118
+ # Convert long github urls into short git.io ones
119
+ #
120
+ # @param [String] url
121
+ # @return [String] shortened url, or long url if shortening fails
122
+ def shorten(url)
123
+ request = Net::HTTP::Post.new("/")
124
+ request.set_form_data(:url => url)
125
+ response = http(GIT_IO_URL, request)
126
+ case response.code
127
+ when "201"
128
+ response['Location']
133
129
  else
134
- http = Net::HTTP.new(url.host, url.port)
130
+ url
135
131
  end
132
+ end
136
133
 
137
- http.use_ssl = true
138
- http.verify_mode = OpenSSL::SSL::VERIFY_PEER
139
- http.ca_file = ca_cert
140
-
141
- req = Net::HTTP::Post.new(url.path)
142
- req.body = JSON.generate(data(files, private_gist, description))
143
-
144
- user, password = auth()
145
- if user && password
146
- req.basic_auth(user, password)
134
+ # Log the user into gist.
135
+ #
136
+ # This method asks the user for a username and password, and tries to obtain
137
+ # and OAuth2 access token, which is then stored in ~/.gist
138
+ #
139
+ # @raise [Gist::Error] if something went wrong
140
+ # @see http://developer.github.com/v3/oauth/
141
+ def login!
142
+ puts "Obtaining OAuth2 access_token from github."
143
+ print "GitHub username: "
144
+ username = $stdin.gets.strip
145
+ print "GitHub password: "
146
+ password = begin
147
+ `stty -echo` rescue nil
148
+ $stdin.gets.strip
149
+ ensure
150
+ `stty echo` rescue nil
147
151
  end
148
-
149
- response = http.start{|h| h.request(req) }
150
- case response
151
- when Net::HTTPCreated
152
- JSON.parse(response.body)['html_url']
152
+ puts ""
153
+
154
+ request = Net::HTTP::Post.new("#{base_path}/authorizations")
155
+ request.body = JSON.dump({
156
+ :scopes => [:gist],
157
+ :note => "The gist gem",
158
+ :note_url => "https://github.com/ConradIrwin/gist"
159
+ })
160
+ request.content_type = 'application/json'
161
+ request.basic_auth(username, password)
162
+
163
+ response = http(api_url, request)
164
+
165
+ if Net::HTTPCreated === response
166
+ File.open(auth_token_file, 'w') do |f|
167
+ f.write JSON.parse(response.body)['token']
168
+ end
169
+ puts "Success! #{ENV[URL_ENV_NAME] || "https://github.com/"}settings/applications"
153
170
  else
154
- puts "Creating gist failed: #{response.code} #{response.message}"
155
- exit(false)
171
+ raise "Got #{response.class} from gist: #{response.body}"
156
172
  end
173
+ rescue => e
174
+ raise e.extend Error
157
175
  end
158
176
 
159
- # Given a gist id, returns its content.
160
- def read(gist_id)
161
- data = JSON.parse(open(GIST_URL % gist_id).read)
162
- data["files"].map{|name, content| content['content'] }.join("\n\n")
177
+ # Return HTTP connection
178
+ #
179
+ # @param [URI::HTTP] The URI to which to connect
180
+ # @return [Net::HTTP]
181
+ def http_connection(uri)
182
+ env = ENV['http_proxy'] || ENV['HTTP_PROXY']
183
+ connection = if env
184
+ proxy = URI(env)
185
+ Net::HTTP::Proxy(proxy.host, proxy.port).new(uri.host, uri.port)
186
+ else
187
+ Net::HTTP.new(uri.host, uri.port)
188
+ end
189
+ if uri.scheme == "https"
190
+ connection.use_ssl = true
191
+ connection.verify_mode = OpenSSL::SSL::VERIFY_NONE
192
+ end
193
+ connection.open_timeout = 10
194
+ connection.read_timeout = 10
195
+ connection
163
196
  end
164
197
 
165
- # Given a url, tries to open it in your browser.
166
- # TODO: Linux
167
- def browse(url)
168
- if RUBY_PLATFORM =~ /darwin/
169
- `open #{url}`
170
- elsif RUBY_PLATFORM =~ /linux/
171
- `#{ENV['BROWSER']} #{url}`
172
- elsif ENV['OS'] == 'Windows_NT' or
173
- RUBY_PLATFORM =~ /djgpp|(cyg|ms|bcc)win|mingw|wince/i
174
- `start "" "#{url}"`
198
+ # Run an HTTP operation
199
+ #
200
+ # @param [URI::HTTP] The URI to which to connect
201
+ # @param [Net::HTTPRequest] The request to make
202
+ # @return [Net::HTTPResponse]
203
+ def http(url, request)
204
+ request['User-Agent'] = USER_AGENT
205
+
206
+ http_connection(url).start do |http|
207
+ http.request request
175
208
  end
209
+ rescue Timeout::Error
210
+ raise "Could not connect to #{api_url}"
176
211
  end
177
212
 
178
- # Tries to copy passed content to the clipboard.
213
+ # Called after an HTTP response to gist to perform post-processing.
214
+ #
215
+ # @param [String] body the text body from the github api
216
+ # @param [Hash] options more detailed options, see
217
+ # the documentation for {multi_gist}
218
+ def on_success(body, options={})
219
+ json = JSON.parse(body)
220
+
221
+ output = case options[:output]
222
+ when :javascript
223
+ %Q{<script src="#{json['html_url']}.js"></script>}
224
+ when :html_url
225
+ json['html_url']
226
+ when :short_url
227
+ shorten(json['html_url'])
228
+ else
229
+ json
230
+ end
231
+
232
+ Gist.copy(output.to_s) if options[:copy]
233
+ Gist.open(json['html_url']) if options[:open]
234
+
235
+ output
236
+ end
237
+
238
+ # Copy a string to the clipboard.
239
+ #
240
+ # @param [String] content
241
+ # @raise [Gist::Error] if no clipboard integration could be found
242
+ #
179
243
  def copy(content)
180
- cmd = case true
181
- when system("type pbcopy > /dev/null 2>&1")
182
- :pbcopy
183
- when system("type xclip > /dev/null 2>&1")
184
- :xclip
185
- when system("type putclip > /dev/null 2>&1")
186
- :putclip
187
- end
244
+ IO.popen(clipboard_command(:copy), 'r+') { |clip| clip.print content }
188
245
 
189
- if cmd
190
- IO.popen(cmd.to_s, 'r+') { |clip| clip.print content }
191
- end
246
+ unless paste == content
247
+ message = 'Copying to clipboard failed.'
192
248
 
193
- content
194
- end
249
+ if ENV["TMUX"] && clipboard_command(:copy) == 'pbcopy'
250
+ message << "\nIf you're running tmux on a mac, try http://robots.thoughtbot.com/post/19398560514/how-to-copy-and-paste-with-tmux-on-mac-os-x"
251
+ end
195
252
 
196
- private
197
- # Give an array of file information and private boolean, returns
198
- # an appropriate payload for POSTing to gist.github.com
199
- def data(files, private_gist, description)
200
- i = 0
201
- file_data = {}
202
- files.each do |file|
203
- i = i + 1
204
- filename = file[:filename] ? file[:filename] : "gistfile#{i}"
205
- file_data[filename] = {:content => file[:input]}
253
+ raise Error, message
206
254
  end
207
-
208
- data = {"files" => file_data}
209
- data.merge!({ 'description' => description }) unless description.nil?
210
- data.merge!({ 'public' => !private_gist })
211
- data
255
+ rescue Error => e
256
+ raise ClipboardError, e.message + "\nAttempted to copy: #{content}"
212
257
  end
213
258
 
214
- # Returns a basic auth string of the user's GitHub credentials if set.
215
- # http://github.com/guides/local-github-config
259
+ # Get a string from the clipboard.
216
260
  #
217
- # Returns an Array of Strings if auth is found: [user, password]
218
- # Returns nil if no auth is found.
219
- def auth
220
- user = config("github.user")
221
- password = config("github.password")
222
-
223
- token = config("github.token")
224
- if password.to_s.empty? && !token.to_s.empty?
225
- abort "Please set GITHUB_PASSWORD or github.password instead of using a token."
226
- end
261
+ # @param [String] content
262
+ # @raise [Gist::Error] if no clipboard integration could be found
263
+ def paste
264
+ `#{clipboard_command(:paste)}`
265
+ end
227
266
 
228
- if user.to_s.empty? || password.to_s.empty?
267
+ # Find command from PATH environment.
268
+ #
269
+ # @param [String] cmd command name to find
270
+ # @param [String] options PATH environment variable
271
+ # @return [String] the command found
272
+ def which(cmd, path=ENV['PATH'])
273
+ if RUBY_PLATFORM.downcase =~ /mswin(?!ce)|mingw|bccwin|cygwin/
274
+ path.split(File::PATH_SEPARATOR).each {|dir|
275
+ f = File.join(dir, cmd+".exe")
276
+ return f if File.executable?(f) && !File.directory?(f)
277
+ }
229
278
  nil
230
279
  else
231
- [ user, password ]
280
+ return system("which #{cmd} > /dev/null 2>&1")
232
281
  end
233
282
  end
234
283
 
235
- # Returns default values based on settings in your gitconfig. See
236
- # git-config(1) for more information.
237
- #
238
- # Settings applicable to gist.rb are:
284
+ # Get the command to use for the clipboard action.
239
285
  #
240
- # gist.private - boolean
241
- # gist.extension - string
242
- def defaults
243
- extension = config("gist.extension")
244
-
245
- return {
246
- "private" => config("gist.private"),
247
- "browse" => config("gist.browse"),
248
- "extension" => extension
249
- }
286
+ # @param [Symbol] action either :copy or :paste
287
+ # @return [String] the command to run
288
+ # @raise [Gist::ClipboardError] if no clipboard integration could be found
289
+ def clipboard_command(action)
290
+ command = CLIPBOARD_COMMANDS.keys.detect do |cmd|
291
+ which cmd
292
+ end
293
+ raise ClipboardError, <<-EOT unless command
294
+ Could not find copy command, tried:
295
+ #{CLIPBOARD_COMMANDS.values.join(' || ')}
296
+ EOT
297
+ action == :copy ? command : CLIPBOARD_COMMANDS[command]
250
298
  end
251
299
 
252
- # Reads a config value using:
253
- # => Environment: GITHUB_PASSWORD, GITHUB_USER
254
- # like vim gist plugin
255
- # => git-config(1)
300
+ # Open a URL in a browser.
301
+ #
302
+ # @param [String] url
303
+ # @raise [RuntimeError] if no browser integration could be found
256
304
  #
257
- # return something useful or nil
258
- def config(key)
259
- env_key = ENV[key.upcase.gsub(/\./, '_')]
260
- return env_key if env_key and not env_key.strip.empty?
305
+ # This method was heavily inspired by defunkt's Gist#open,
306
+ # @see https://github.com/defunkt/gist/blob/bca9b29/lib/gist.rb#L157
307
+ def open(url)
308
+ command = if ENV['BROWSER']
309
+ ENV['BROWSER']
310
+ elsif RUBY_PLATFORM =~ /darwin/
311
+ 'open'
312
+ elsif RUBY_PLATFORM =~ /linux/
313
+ %w(
314
+ sensible-browser
315
+ firefox
316
+ firefox-bin
317
+ ).detect do |cmd|
318
+ which cmd
319
+ end
320
+ elsif ENV['OS'] == 'Windows_NT' || RUBY_PLATFORM =~ /djgpp|(cyg|ms|bcc)win|mingw|wince/i
321
+ 'start ""'
322
+ else
323
+ raise "Could not work out how to use a browser."
324
+ end
325
+
326
+ `#{command} #{url}`
327
+ end
261
328
 
262
- str_to_bool `git config --global #{key}`.strip
329
+ # Get the API base path
330
+ def base_path
331
+ ENV.key?(URL_ENV_NAME) ? GHE_BASE_PATH : GITHUB_BASE_PATH
263
332
  end
264
333
 
265
- # Parses a value that might appear in a .gitconfig file into
266
- # something useful in a Ruby script.
267
- def str_to_bool(str)
268
- if str.size > 0 and str[0].chr == '!'
269
- command = str[1, str.length]
270
- value = `#{command}`
271
- else
272
- value = str
273
- end
334
+ # Get the API URL
335
+ def api_url
336
+ ENV.key?(URL_ENV_NAME) ? URI(ENV[URL_ENV_NAME]) : GITHUB_API_URL
337
+ end
274
338
 
275
- case value.downcase.strip
276
- when "false", "0", "nil", "", "no", "off"
277
- nil
278
- when "true", "1", "yes", "on"
279
- true
339
+ def auth_token_file
340
+ if ENV.key?(URL_ENV_NAME)
341
+ File.expand_path "~/.gist.#{ENV[URL_ENV_NAME].gsub(/[^a-z.]/, '')}"
280
342
  else
281
- value
343
+ File.expand_path "~/.gist"
282
344
  end
283
345
  end
284
346
 
285
- def ca_cert
286
- cert_file = [
287
- File.expand_path("../gist/cacert.pem", __FILE__),
288
- "/tmp/gist_cacert.pem"
289
- ].find{|l| File.exist?(l) }
347
+ def legacy_private_gister?
348
+ return unless which('git')
349
+ `git config --global gist.private` =~ /\Ayes|1|true|on\z/i
350
+ end
290
351
 
291
- if cert_file
292
- cert_file
352
+ def should_be_public?(options={})
353
+ if options.key? :private
354
+ !options[:private]
293
355
  else
294
- File.open("/tmp/gist_cacert.pem", "w") do |f|
295
- f.write(DATA.read.split("__CACERT__").last)
296
- end
297
- "/tmp/gist_cacert.pem"
356
+ !Gist.legacy_private_gister?
298
357
  end
299
358
  end
300
-
301
359
  end
@@ -0,0 +1,40 @@
1
+ describe '...' do
2
+ before do
3
+ @saved_path = ENV['PATH']
4
+ @bobo_url = 'http://example.com'
5
+ end
6
+
7
+ after do
8
+ ENV['PATH'] = @saved_path
9
+ end
10
+
11
+ def ask_for_copy
12
+ Gist.on_success({'html_url' => @bobo_url}.to_json, :copy => true, :output => :html_url)
13
+ end
14
+ def gist_but_dont_ask_for_copy
15
+ Gist.on_success({'html_url' => 'http://example.com/'}.to_json, :output => :html_url)
16
+ end
17
+
18
+ it 'should try to copy the url when the clipboard option is passed' do
19
+ Gist.should_receive(:copy).with(@bobo_url)
20
+ ask_for_copy
21
+ end
22
+
23
+ it 'should try to copy the embed url when the clipboard-js option is passed' do
24
+ js_link = %Q{<script src="#{@bobo_url}.js"></script>}
25
+ Gist.should_receive(:copy).with(js_link)
26
+ Gist.on_success({'html_url' => @bobo_url}.to_json, :copy => true, :output => :javascript)
27
+ end
28
+
29
+ it "should not copy when not asked to" do
30
+ Gist.should_not_receive(:copy).with(@bobo_url)
31
+ gist_but_dont_ask_for_copy
32
+ end
33
+
34
+ it "should raise an error if no copying mechanisms are available" do
35
+ ENV['PATH'] = ''
36
+ lambda{
37
+ ask_for_copy
38
+ }.should raise_error(/Could not find copy command.*http/m)
39
+ end
40
+ end