gistribute 0.2 → 0.3

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/cli.rb ADDED
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cli/auth"
4
+ require "cli/install"
5
+ require "cli/upload"
6
+
7
+ module Gistribute
8
+ class CLI
9
+ def initialize
10
+ @options = OptimistXL.options do
11
+ version "gistribute #{File.read(File.expand_path('../VERSION', __dir__)).strip}"
12
+
13
+ banner <<~BANNER
14
+ #{version}
15
+
16
+ Usage:
17
+ gistribute SUBCOMMAND [OPTION]... INPUT
18
+
19
+ Try `gistribute SUBCOMMAND -h` for more info. Available subcommands:
20
+ * login: log into your GitHub account
21
+ * logout: log out of your GitHub account
22
+ * install: download and install files from a gistribution
23
+ * upload: upload a new gistribution
24
+
25
+ Options:
26
+ BANNER
27
+
28
+ opt :version, "display version number"
29
+ opt :help, "display a help message"
30
+
31
+ # Sub-commands can't access the version from this scope for whatever reason
32
+ v = version
33
+
34
+ subcmd :login, "log into your GitHub account"
35
+ subcmd :logout, "log out of your GitHub account"
36
+
37
+ subcmd :install, "install from a gistribution" do
38
+ banner <<~BANNER
39
+ #{v}
40
+
41
+ Usage:
42
+ gistribute install [OPTION]... URL_OR_ID
43
+
44
+ Options:
45
+ BANNER
46
+
47
+ opt :yes, "install files without prompting"
48
+ opt :force, "overwrite existing files without prompting"
49
+ end
50
+
51
+ subcmd :upload, "upload a gistribution" do
52
+ banner <<~BANNER
53
+ #{v}
54
+
55
+ Usage:
56
+ gistribute upload [OPTION]... FILE...
57
+ gistribute upload [OPTION]... DIRECTORY
58
+
59
+ Options:
60
+ BANNER
61
+
62
+ opt :description, "description for the Gist", type: :string
63
+ opt :private, "use a private Gist"
64
+ opt :yes, "upload files without prompting"
65
+ end
66
+
67
+ educate_on_error
68
+ end
69
+
70
+ @subcommand, @global_options, @subcommand_options =
71
+ @options.subcommand, @options.global_options, @options.subcommand_options
72
+
73
+ authenticate unless @subcommand == "logout"
74
+
75
+ case @subcommand
76
+ when "install"
77
+ @gist_input = ARGV.first
78
+ when "upload"
79
+ @files = ARGV.dup
80
+ end
81
+ end
82
+
83
+ def run
84
+ case @subcommand
85
+ when "login"
86
+ # Do nothing, #authenticate is run from the constructor
87
+ when "logout"
88
+ FileUtils.rm_rf CONFIG_FILE
89
+ puts "Logged out.".green
90
+ else
91
+ if ARGV.empty?
92
+ OptimistXL.educate
93
+ end
94
+
95
+ eval @subcommand
96
+ end
97
+ end
98
+
99
+ def confirm?(prompt)
100
+ print prompt
101
+ input = $stdin.gets.strip.downcase
102
+
103
+ input.start_with?("y") || input.empty?
104
+ end
105
+
106
+ def get_input(prompt)
107
+ print prompt
108
+ $stdin.gets.strip
109
+ end
110
+
111
+ # Prints an error message and exits the program.
112
+ def panic!(message)
113
+ $stderr.puts "#{'Error'.red}: #{message}"
114
+ exit 1
115
+ end
116
+ end
117
+ end
data/lib/gistribute.rb ADDED
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "colorize"
4
+ require "fileutils"
5
+ require "json"
6
+ require "launchy"
7
+ require "octokit"
8
+ require "optimist_xl"
9
+
10
+ require "cli"
11
+
12
+ CLIENT_ID = "3f37dc8255e5ab891c3d"
13
+ CONFIG_FILE = "#{Dir.home}/.config/gistribute".freeze
14
+
15
+ module Gistribute
16
+ class << self
17
+ # The user may enter either the full URL or just the ID, this function
18
+ # will parse it out of the input.
19
+ def parse_id(str)
20
+ str[%r{(^|/)([[:xdigit:]]+)}, 2]
21
+ end
22
+
23
+ # Encodes a file path for use in the Gist filename
24
+ def encode(filename)
25
+ filename.sub(/^#{Dir.home}/, "~").gsub("/", "|")
26
+ end
27
+
28
+ # Decodes the filename from the Gist into a usable path
29
+ def decode(filename)
30
+ filename.gsub(/[~|]/, "|" => "/", "~" => Dir.home)
31
+ end
32
+ end
33
+ end
data/spec/.rubocop.yml ADDED
@@ -0,0 +1,46 @@
1
+ inherit_from: ../.rubocop.yml
2
+
3
+ RSpec/Focus:
4
+ Severity: warning
5
+
6
+ Style/SignalException:
7
+ Enabled: false
8
+
9
+ # Lots of spacing for alignment in the tests
10
+ Layout/SpaceInsideArrayLiteralBrackets:
11
+ Enabled: false
12
+
13
+ Lint/AmbiguousBlockAssociation:
14
+ Enabled: false
15
+
16
+ ################################################################################
17
+
18
+ RSpec/ContextWording:
19
+ Enabled: false
20
+
21
+ RSpec/EmptyExampleGroup:
22
+ Enabled: false
23
+
24
+ RSpec/ExampleLength:
25
+ Enabled: false
26
+
27
+ RSpec/InstanceVariable:
28
+ Enabled: false
29
+
30
+ RSpec/MultipleExpectations:
31
+ Enabled: false
32
+
33
+ RSpec/NestedGroups:
34
+ Enabled: false
35
+
36
+ RSpec/NoExpectationExample:
37
+ Enabled: false
38
+
39
+ RSpec/ScatteredLet:
40
+ Enabled: false
41
+
42
+ RSpec/SortMetadata:
43
+ Enabled: false
44
+
45
+ RSpec/VerifiedDoubles:
46
+ Enabled: false
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ DEVICE_RES = {
6
+ "device_code" => "testdevicecode",
7
+ "expires_in" => "899",
8
+ "interval" => "1",
9
+ "user_code" => "1337-6969",
10
+ "verification_uri" => "https://github.com/login/device"
11
+ }.freeze
12
+
13
+ PENDING_RES = {
14
+ "error" => "authorization_pending",
15
+ "error_description" => "The authorization request is still pending.",
16
+ "error_uri" => "https://docs.github.com/developers/apps/authorizing-oauth-apps#error-codes-for-the-device-flow"
17
+ }.freeze
18
+
19
+ EXPIRED_RES = {
20
+ "error" => "expired_token",
21
+ "error_description" => "The `device code` has expired.",
22
+ "error_uri" => "https://docs.github.com/developers/apps/authorizing-oauth-apps#error-codes-for-the-device-flow"
23
+ }.freeze
24
+
25
+ SUCCESS_RES = {
26
+ "access_token" => OAUTH_TOKEN,
27
+ "scope" => "gist",
28
+ "token_type" => "bearer"
29
+ }.freeze
30
+
31
+ def mock_oauth_response(res)
32
+ allow(Net::HTTP).to receive(:post_form)
33
+ .with(URI("https://github.com/login/oauth/access_token"), anything)
34
+ .and_return(:res2)
35
+ allow(URI).to receive(:decode_www_form)
36
+ .with(:res2)
37
+ .and_return(res)
38
+ end
39
+
40
+ # Hack to get the `:resX` symbols to fall through into #decode_www_form
41
+ class Symbol
42
+ def body
43
+ itself
44
+ end
45
+ end
46
+
47
+ describe Gistribute::CLI do
48
+ before do
49
+ allow(Launchy).to receive(:open)
50
+ allow(File).to receive(:write)
51
+ suppress_stdout
52
+ end
53
+
54
+ let(:http_ok) { Net::HTTPOK.new("1.1", "200", "OK") }
55
+
56
+ describe "#authenticate" do
57
+ context "when there is no access key saved" do
58
+ before do
59
+ allow(File).to receive(:exist?).and_return(false)
60
+
61
+ allow(Net::HTTP).to receive(:post_form)
62
+ .with(URI("https://github.com/login/device/code"), anything)
63
+ .and_return(:res1)
64
+ allow(URI).to receive(:decode_www_form)
65
+ .with(:res1)
66
+ .and_return(DEVICE_RES.to_a)
67
+ end
68
+
69
+ describe "the initial output" do
70
+ before do
71
+ mock_oauth_response SUCCESS_RES
72
+ run "login"
73
+ end
74
+
75
+ it "prints the user code" do
76
+ expect($stdout).to have_received(:write)
77
+ .with(a_string_matching DEVICE_RES["user_code"])
78
+ end
79
+
80
+ it "prints the verification URI" do
81
+ expect($stdout).to have_received(:write)
82
+ .with(a_string_matching DEVICE_RES["verification_uri"])
83
+ end
84
+
85
+ it "opens the verification URI in a browser" do
86
+ expect(Launchy).to have_received(:open).with(DEVICE_RES["verification_uri"])
87
+ end
88
+ end
89
+
90
+ describe "when the response is a success" do
91
+ before do
92
+ mock_oauth_response SUCCESS_RES
93
+ allow(Octokit::Client).to receive(:new).and_call_original
94
+ run "login"
95
+ end
96
+
97
+ it "writes the token to the config file" do
98
+ expect(File).to have_received(:write).with(CONFIG_FILE, OAUTH_TOKEN)
99
+ end
100
+
101
+ it "logs in with Octokit" do
102
+ expect(Octokit::Client).to have_received(:new)
103
+ .with(a_hash_including access_token: OAUTH_TOKEN)
104
+ end
105
+ end
106
+
107
+ context "when the response is timed out" do
108
+ before do
109
+ mock_oauth_response EXPIRED_RES
110
+ %i[puts print].each { |p| allow($stderr).to receive p }
111
+ run "login", fail_on_exit: false
112
+ end
113
+
114
+ let(:error) { "#{'Error'.red}: Token expired! Please try again." }
115
+
116
+ it "displays an error" do
117
+ expect($stderr).to have_received(:puts).with(error)
118
+ end
119
+ end
120
+ end
121
+ end
122
+
123
+ describe "the `logout` subcommand" do
124
+ before do
125
+ allow(FileUtils).to receive(:rm_rf)
126
+ run "logout"
127
+ end
128
+
129
+ it "deletes the auth token" do
130
+ expect(FileUtils).to have_received(:rm_rf).with(CONFIG_FILE)
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ NEW_DIR_FILENAME = "nested/test.file"
6
+ MULTI_FILENAMES = ["file1", "dir/file2"].freeze
7
+
8
+ SINGLE_FILE_CONTENTS = "Line 1\nLine 2\n"
9
+
10
+ # Test Gists
11
+ PUB_SINGLE_FILE_ID = "4346763"
12
+ SEC_SINGLE_FILE_ID = "5fa3c6bab88036e95d62cadf15128ec3"
13
+ NO_TITLE_ID = "5865a130f9cf40acd9f0e85d15e601a7"
14
+ NO_PIPE_SPACING_ID = "5677679b0db25054521753e5d59bed3d"
15
+ NON_EXISTENT_DIR_ID = "8c86bf9cda921ebe7ad1bf0c46afb108"
16
+ MULTI_FILE_ID = "8d4a2a4c8fe0b1427fed39c939857a40"
17
+ CWD_ID = "3c7006e629fcc54262ef02a5b2204735"
18
+ HOME_ID = "acb6caa80886101a68c5c85e4c100ddb"
19
+
20
+ def test_single_file(id, path)
21
+ before { run "install", id }
22
+
23
+ # TEMP already gets `rm -rf`ed in the `cli_spec.rb` #after
24
+ unless path == TEMP
25
+ after { FileUtils.rm "#{path}/#{FILENAME}" }
26
+ end
27
+
28
+ let(:file_contents) { File.read "#{path}/#{FILENAME}" }
29
+
30
+ it "downloads the file into #{path}" do
31
+ expect(file_contents).to eq SINGLE_FILE_CONTENTS
32
+ end
33
+ end
34
+
35
+ describe Gistribute::CLI do
36
+ before do
37
+ FileUtils.rm_rf TEMP
38
+ FileUtils.mkdir_p TEMP
39
+ suppress_stdout
40
+ end
41
+
42
+ after { FileUtils.rm_rf TEMP }
43
+
44
+ describe "#install" do
45
+ context "when user inputs `y` at the installation prompt" do
46
+ before { simulate_user_input "y\n" }
47
+
48
+ {
49
+ "public single file": PUB_SINGLE_FILE_ID,
50
+ "secret single file": SEC_SINGLE_FILE_ID,
51
+ "no title": NO_TITLE_ID,
52
+ "no || spacing": NO_PIPE_SPACING_ID
53
+ }.each do |description, id|
54
+ context "when run with a #{description} Gist" do
55
+ test_single_file id, TEMP
56
+ end
57
+ end
58
+
59
+ context "when given a directory that doesn't exist" do
60
+ before { run "install", NON_EXISTENT_DIR_ID }
61
+
62
+ let(:file_contents) { File.read "#{TEMP}/#{NEW_DIR_FILENAME}" }
63
+
64
+ it "creates the directory" do
65
+ expect(file_contents).to eq SINGLE_FILE_CONTENTS
66
+ end
67
+ end
68
+
69
+ context "when run with a multi-file Gist" do
70
+ before { run "install", MULTI_FILE_ID }
71
+
72
+ let(:file1_contents) { File.read "#{TEMP}/#{MULTI_FILENAMES[0]}" }
73
+ let(:file2_contents) { File.read "#{TEMP}/#{MULTI_FILENAMES[1]}" }
74
+
75
+ it "downloads the files into the correct locations" do
76
+ [file1_contents, file2_contents].each_with_index do |result, i|
77
+ file_number = i + 1
78
+ expect(result).to eq "F#{file_number}L1\nF#{file_number}L2\n"
79
+ end
80
+ end
81
+ end
82
+
83
+ context "when given a file for the current working directory" do
84
+ test_single_file CWD_ID, Dir.pwd
85
+ end
86
+
87
+ context "when given a file for the home directory" do
88
+ test_single_file HOME_ID, Dir.home
89
+ end
90
+
91
+ context "when given a bad ID (404)" do
92
+ before do
93
+ %i[puts print].each { |p| allow($stderr).to receive p }
94
+ run "install", "bad", fail_on_exit: false
95
+ end
96
+
97
+ it "prints the error to STDERR" do
98
+ expect($stderr).to have_received(:print).with <<~EOS.chop.red
99
+ \rThere was an error downloading the requested Gist.
100
+ The error is as follows:
101
+ EOS
102
+
103
+ expect($stderr).to have_received(:puts).with(" 404 Not Found")
104
+ expect($stderr).to have_received(:print).with("The ID that was queried is: ".red)
105
+ expect($stderr).to have_received(:puts).with("bad")
106
+ end
107
+ end
108
+ end
109
+
110
+ context "when user inputs nothing at the installation prompt" do
111
+ before do
112
+ simulate_user_input "\n"
113
+ run "install", PUB_SINGLE_FILE_ID
114
+ end
115
+
116
+ it "saves the file" do
117
+ expect(File).to exist("#{TEMP}/#{FILENAME}")
118
+ end
119
+ end
120
+
121
+ %w[n m].each do |ch|
122
+ context "when user inputs `#{ch}` at the installation prompt" do
123
+ before do
124
+ simulate_user_input "#{ch}\n"
125
+ run "install", PUB_SINGLE_FILE_ID
126
+ end
127
+
128
+ it "doesn't save the file" do
129
+ expect(File).not_to exist("#{TEMP}/#{FILENAME}")
130
+ end
131
+ end
132
+ end
133
+
134
+ context "when a file would be overwritten" do
135
+ let(:orig_content) { "original" }
136
+
137
+ before do
138
+ FileUtils.mkdir_p "#{TEMP}/dir"
139
+ MULTI_FILENAMES.each do |filename|
140
+ File.write("#{TEMP}/#{filename}", orig_content)
141
+ end
142
+ end
143
+
144
+ let(:file1_contents) { File.read "#{TEMP}/#{MULTI_FILENAMES[0]}" }
145
+ let(:file2_contents) { File.read "#{TEMP}/#{MULTI_FILENAMES[1]}" }
146
+
147
+ context "when the user inputs `y` at the file overwrite prompts" do
148
+ before do
149
+ simulate_user_input "y\n", "y\n", "y\n"
150
+ run "install", MULTI_FILE_ID
151
+ end
152
+
153
+ it "downloads the files into the correct locations" do
154
+ expect([file1_contents, file2_contents]).not_to include orig_content
155
+ end
156
+ end
157
+
158
+ context "when the user inputs `n` at the file overwrite prompts" do
159
+ before do
160
+ simulate_user_input "y\n", "n\n", "n\n"
161
+ run "install", MULTI_FILE_ID
162
+ end
163
+
164
+ it "doesn't download the files" do
165
+ expect([file1_contents, file2_contents]).to all eq orig_content
166
+ end
167
+ end
168
+
169
+ context "with the `--force` flag" do
170
+ before do
171
+ simulate_user_input "y\n"
172
+ run "install", "--force", MULTI_FILE_ID
173
+ end
174
+
175
+ it "overwrites the files without prompting" do
176
+ [file1_contents, file2_contents].each do |result|
177
+ expect(result).not_to eq orig_content
178
+ end
179
+ end
180
+ end
181
+ end
182
+
183
+ context "when ran with the --yes flag" do
184
+ before { run "install", "--yes", PUB_SINGLE_FILE_ID }
185
+
186
+ let(:file_contents) { File.read "#{TEMP}/#{FILENAME}" }
187
+
188
+ it "downloads the file without prompting" do
189
+ expect(file_contents).to eq SINGLE_FILE_CONTENTS
190
+ end
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ describe Gistribute::CLI do
6
+ before { suppress_stdout }
7
+
8
+ %w[install upload].each do |subcmd|
9
+ describe "the `#{subcmd}` subcommand" do
10
+ context "when no argument is provided" do
11
+ it "shows the help screen" do
12
+ allow(OptimistXL).to receive(:educate).and_call_original
13
+ run subcmd, fail_on_exit: false
14
+
15
+ expect(OptimistXL).to have_received :educate
16
+ end
17
+ end
18
+ end
19
+ end
20
+
21
+ context "when run with the `--version` flag" do
22
+ let :version do
23
+ File.read(File.expand_path("../../VERSION", __dir__)).strip
24
+ end
25
+
26
+ it "outputs the version number" do
27
+ expect { run "--version", fail_on_exit: false }
28
+ .to output(a_string_matching(version)).to_stdout
29
+ end
30
+ end
31
+ end