diff --git a/.gitignore b/.gitignore index a4338360b5..f33da2d00d 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ .rubocop-* +.tmp/* +./ngrok* diff --git a/.tmp/.gitkeep b/.tmp/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lib/shopify-cli/commands/tunnel.rb b/lib/shopify-cli/commands/tunnel.rb index 6934181b7a..e2556f6e84 100644 --- a/lib/shopify-cli/commands/tunnel.rb +++ b/lib/shopify-cli/commands/tunnel.rb @@ -7,8 +7,17 @@ module Commands class Tunnel < ShopifyCli::Command # subcommands :start, :stop - def call(_args, _name) - puts CLI::UI.fmt(self.class.help) + def call(args, _name) + subcommand = args.shift + task = ShopifyCli::Tasks::Tunnel.new + case subcommand + when 'start' + task.call(@ctx) + when 'stop' + task.stop(@ctx) + else + puts CLI::UI.fmt(self.class.help) + end end def self.help diff --git a/lib/shopify-cli/context.rb b/lib/shopify-cli/context.rb index 48a34b1eec..c4ac5c472d 100644 --- a/lib/shopify-cli/context.rb +++ b/lib/shopify-cli/context.rb @@ -24,7 +24,11 @@ def write(fname, content) end def puts(*args) - Kernel.puts(*args) + Kernel.puts(CLI::UI.fmt(*args)) + end + + def spawn(*args) + Kernel.spawn(*args) end def method_missing(method, *args) diff --git a/lib/shopify-cli/tasks/tunnel.rb b/lib/shopify-cli/tasks/tunnel.rb index 3cc1e42d9c..183c6d1ccc 100644 --- a/lib/shopify-cli/tasks/tunnel.rb +++ b/lib/shopify-cli/tasks/tunnel.rb @@ -1,10 +1,144 @@ +require 'json' +require 'tempfile' require 'shopify_cli' module ShopifyCli module Tasks class Tunnel < ShopifyCli::Task - def call(ctx, *) - ctx.puts('success!') + class FetchUrlError < RuntimeError; end + class NgrokError < RuntimeError; end + + PORT = 8081 + DOWNLOAD_URLS = { + mac: 'https://bin.equinox.io/c/4VmDzA7iaHb/ngrok-stable-darwin-amd64.zip', + } + TIMEOUT = 5 + + def call(ctx) + @ctx = ctx + start + end + + def stop(ctx) + @ctx = ctx + if running? + Process.kill(9, state[:pid]) + FileUtils.rm(pid_file) + @ctx.puts("{{green:x}} ngrok tunnel stopped") + else + @ctx.puts("{{green:x}} ngrok tunnel not running") + end + end + + def start + install + + url = if running? + state[:url] + else + run + end + @ctx.puts("{{green:✔︎}} ngrok tunnel running at #{url}") + url + end + + def run + pid = @ctx.spawn(ngrok_command, chdir: ShopifyCli::ROOT) + Process.detach(pid) + url = fetch_url + write_state(pid, url, Time.now) + url + end + + private + + def running? + if File.exist?(pid_file) + state = read_state + begin + Process.kill(0, state[:pid]) + true + rescue Errno::ESRCH + false + rescue Errno::EPERM + false + end + else + false + end + end + + def read_state + content = JSON.parse(File.open(pid_file).read) + { + pid: content['pid'], + url: content['url'], + time: content['time'], + } + end + + def write_state(pid, url, time) + File.open(pid_file, 'w') do |f| + f.write({ + pid: pid, + url: url, + time: time, + }.to_json) + end + end + + def install + return if File.exist?(File.join(ShopifyCli::ROOT, 'ngrok')) + spinner = CLI::UI::SpinGroup.new + spinner.add('Installing ngrok...') do + zip_dest = File.join(ShopifyCli::ROOT, 'ngrok.zip') + unless File.exist?(zip_dest) + @ctx.system('curl', '-o', zip_dest, DOWNLOAD_URLS[:mac], chdir: ShopifyCli::ROOT) + end + @ctx.system('unzip', '-u', zip_dest, chdir: ShopifyCli::ROOT) + FileUtils.rm(zip_dest) + end + spinner.wait + end + + def fetch_url + counter = 0 + while counter < TIMEOUT + log_content = File.read(log) + result = log_content.match(/msg="started tunnel".*url=(https:\/\/.+)/) + return result[1] if result + + counter += 1 + sleep(1) + end + + error = log_content.scan(/msg="command failed" err="([^"]+)"/).flatten + unless error.empty? + stop(@ctx) + raise NgrokError, error.first + end + + raise FetchUrlError, "Unable to fetch external url" + end + + def ngrok_command + "exec #{File.join(ShopifyCli::ROOT, 'ngrok')} http -log=stdout -log-level=debug #{PORT} > #{log}" + end + + def log + @log ||= begin + fname = File.join(ShopifyCli::ROOT, '.tmp', 'ngrok.log') + FileUtils.touch(fname) + File.join(fname) + end + end + + def pid_file + File.join(ShopifyCli::ROOT, '.tmp/ngrok.pid') + end + + def state + @state ||= read_state end end end diff --git a/test/commands/tunnel_test.rb b/test/commands/tunnel_test.rb new file mode 100644 index 0000000000..737deb7d04 --- /dev/null +++ b/test/commands/tunnel_test.rb @@ -0,0 +1,21 @@ +require 'test_helper' + +module ShopifyCli + module Commands + class TunnelTest < MiniTest::Test + def setup + @command = ShopifyCli::Commands::Tunnel.new + end + + def test_start + ShopifyCli::Tasks::Tunnel.any_instance.stubs(:call) + @command.call(['start'], nil) + end + + def test_stop + ShopifyCli::Tasks::Tunnel.any_instance.stubs(:stop) + @command.call(['stop'], nil) + end + end + end +end diff --git a/test/fixtures/ngrok.log b/test/fixtures/ngrok.log new file mode 100644 index 0000000000..0560c14893 --- /dev/null +++ b/test/fixtures/ngrok.log @@ -0,0 +1,3 @@ +t=2019-03-26T14:45:57-0400 lvl=dbug msg="start tunnel listen" obj=tunnels.session name="command_line (http)" proto=http opts="&{Hostname:example.ngrok.io Auth: Subdomain: HostHeaderRewrite:false LocalURLScheme:http}" err=nil +t=2019-03-26T14:45:57-0400 lvl=info msg="started tunnel" obj=tunnels name="command_line (http)" addr=http://localhost:8081 url=http://example.ngrok.io +t=2019-03-26T14:45:57-0400 lvl=info msg="started tunnel" obj=tunnels name=command_line addr=http://localhost:8081 url=https://example.ngrok.io diff --git a/test/fixtures/ngrok.pid b/test/fixtures/ngrok.pid new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/fixtures/ngrok_error.log b/test/fixtures/ngrok_error.log new file mode 100644 index 0000000000..ae4d7079fe --- /dev/null +++ b/test/fixtures/ngrok_error.log @@ -0,0 +1 @@ +t=2019-01-04T10:03:57-0500 lvl=crit msg="command failed" err="Tunnel session failed: Your account may not run more than 8 tunnels over a single ngrok client session." diff --git a/test/shopify-cli/command_registry_test.rb b/test/shopify-cli/command_registry_test.rb index 6b219144bf..996636609d 100644 --- a/test/shopify-cli/command_registry_test.rb +++ b/test/shopify-cli/command_registry_test.rb @@ -5,7 +5,6 @@ class CommandRegistryTest < MiniTest::Test class FakeCommand < ShopifyCli::Command prerequisite_task :fake_task - attr_accessor :ctx def call(_args, _name) @ctx.puts('command!') diff --git a/test/task/tunnel_test.rb b/test/task/tunnel_test.rb new file mode 100644 index 0000000000..bdb967ef01 --- /dev/null +++ b/test/task/tunnel_test.rb @@ -0,0 +1,72 @@ +require 'test_helper' + +module ShopifyCli + module Tasks + class TunnelTest < MiniTest::Test + include TestHelpers::Context + + def setup + ShopifyCli::Tasks::Tunnel.any_instance.stubs(:pid_file).returns(pid_path) + super + FakeFS::FileSystem.clone(pid_path) + end + + def test_start_running_returns_url + stub_binary + ShopifyCli::Tasks::Tunnel.any_instance.stubs(:running?).returns(true) + ShopifyCli::Tasks::Tunnel.any_instance.stubs(:state).returns( + url: 'https://example.ngrok.io', + ) + assert_equal 'https://example.ngrok.io', ShopifyCli::Tasks::Tunnel.new.call(@context) + end + + def test_start_not_running_returns_starts_ngrok + binary = stub_binary + ShopifyCli::Tasks::Tunnel.any_instance.stubs(:running?).returns(false) + with_log do |log_path| + @context.expects(:spawn).with( + "exec #{binary} http -log=stdout -log-level=debug 8081 > #{log_path}", + chdir: ShopifyCli::ROOT + ).returns(1000) + Process.expects(:detach).with(1000) + @context.expects(:puts).with( + "{{green:✔︎}} ngrok tunnel running at https://example.ngrok.io" + ) + assert_equal 'https://example.ngrok.io', ShopifyCli::Tasks::Tunnel.new.call(@context) + end + end + + def test_start_raises_error_on_ngrok_failure + binary = stub_binary + ShopifyCli::Tasks::Tunnel.any_instance.stubs(:running?).returns(false) + with_log('ngrok_error') do |log_path| + @context.expects(:spawn).with( + "exec #{binary} http -log=stdout -log-level=debug 8081 > #{log_path}", + chdir: ShopifyCli::ROOT + ).returns(1000) + assert_raises ShopifyCli::Tasks::Tunnel::NgrokError do + ShopifyCli::Tasks::Tunnel.new.call(@context) + end + end + end + + def with_log(fixture = 'ngrok') + log_path = File.join(File.absolute_path(File.dirname(__FILE__)), "../fixtures/#{fixture}.log") + FakeFS::FileSystem.clone(log_path) + ShopifyCli::Tasks::Tunnel.any_instance.stubs(:log).returns(log_path) + yield(log_path) + end + + def stub_binary + ShopifyCli::Tasks::Tunnel.any_instance.stubs(:install) + binary = File.join(ShopifyCli::ROOT, 'ngrok') + FakeFS::FileSystem.clone(binary) + binary + end + + def pid_path + @pid_path ||= File.join(File.absolute_path(File.dirname(__FILE__)), '../fixtures/ngrok.pid') + end + end + end +end